commit 5a98242f2f153a035fe2004366ccab7bf5b846c4 Author: ZhuJW Date: Thu Apr 16 15:44:32 2026 +0800 第一次 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5385148 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.vscode +data +cmake-build-* +**/build +build_* +build-* +*.bag + +output +work_dirs +archive +.idea +.ruff_cache + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..750068d --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +build +build_* +build-* +output + +.vscode/* +.vscode +!.vscode/c_cpp_properties.json +!.vscode/launch.json + +__pycache__ +**/__pycache__ +data + +*.bag +/cmake-build-*/ +CMakeCache.txt +.idea/ +*.o +*.so +*.a +# ROS related +.catkin_tools +devel +logs + +mmdet3d.egg-info +work_dirs +deep_annotation_inference.h5 +mmdet3d/.mim +mmdet3d/.mim/* +*.pth +*.pkl + +# env config +.env +*.env + +# build output +dist/* + +/.ruff_cache +apps/auto_labeling/auto_labeling.egg-info/dependency_links.txt +apps/auto_labeling/auto_labeling.egg-info/PKG-INFO +apps/auto_labeling/auto_labeling.egg-info/SOURCES.txt +apps/auto_labeling/auto_labeling.egg-info/top_level.txt +apps/auto_labeling/utils/detzero_utils/ops/pointnet2/pointnet2_batch/pointnet2_batch_cuda.cpython-38-x86_64-linux-gnu.so +apps/auto_labeling/utils/detzero_utils/ops/pointnet2/pointnet2_stack/pointnet2_stack_cuda.cpython-38-x86_64-linux-gnu.so +apps/auto_labeling/utils/detzero_utils/ops/roiaware_pool3d/roiaware_pool3d_cuda.cpython-38-x86_64-linux-gnu.so +apps/auto_labeling/utils/detzero_utils/ops/roipoint_pool3d/roipoint_pool3d_cuda.cpython-38-x86_64-linux-gnu.so +apps/auto_labeling.egg-info/dependency_links.txt +apps/auto_labeling.egg-info/PKG-INFO +apps/auto_labeling.egg-info/SOURCES.txt +apps/auto_labeling.egg-info/top_level.txt + + +.tmp_mlflow_params +/fst_data_pipeline/apps/root_db_api/.venv/ +.env.* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..72f194b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,111 @@ +# ============================================= +# GitLab CI for fst_data_pipeline +# ============================================= +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_PIPELINE_SOURCE == "web" + - if: $CI_COMMIT_TAG + - if: $CI_PIPELINE_SOURCE == "schedule" + +variables: + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: 0 + REGISTRY: "artifacts.swf.i.mercedes-benz.com" + IMAGE_NAME: "${REGISTRY}/panguprod-docker/fst_data_pipeline/root_db_api" + DOCKER_CLI_EXPERIMENTAL: "enabled" + +default: + tags: + - shared-xsmall-x86-linux + +# ------------------------------------------------------------------ +stages: + - check-code-jobs + - build-docker-image + +# ------------------------------------------------------------------ +# 1️⃣ 代码格式 & lint 检查 +# ------------------------------------------------------------------ +python-lint-format: + stage: check-code-jobs + image: ${REGISTRY}/panguprod-docker/perception-3d/code-format:v1 + script: + - ./infra/tencent/docker_ci/entrypoint_python_format.sh --check --no-lint --fetch + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_PIPELINE_SOURCE == "web" + +# ------------------------------------------------------------------ +# pytest + 覆盖率 +# ------------------------------------------------------------------ +root-db-api-pytest-coverage: + stage: check-code-jobs + image: ${REGISTRY}/panguprod-docker/fst_data_pipeline/python:3.12-slim-bookworm + needs: ["python-lint-format"] + before_script: + - python -m pip install --user uv + - export PATH=$HOME/.local/bin:$PATH + - cd fst_data_pipeline/apps/root_db_api + - uv sync --dev + script: + - mkdir -p tests/results + - > + uv run pytest + --cov=fst_data_pipeline/apps/root_db_api + --cov-report=term-missing + --cov-report=xml:tests/results/coverage.xml + --cov-report=html:tests/results/htmlcov + --junitxml=tests/results/report.xml + coverage: '/TOTAL.*\s+(\d+%)$/' + artifacts: + when: always + paths: + - fst_data_pipeline/apps/root_db_api/tests/results/htmlcov/ + reports: + junit: fst_data_pipeline/apps/root_db_api/tests/results/report.xml + coverage_report: + coverage_format: cobertura + path: fst_data_pipeline/apps/root_db_api/tests/results/coverage.xml + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_PIPELINE_SOURCE == "web" + +# ------------------------------------------------------------------ +# 2️⃣ 构建并推送 Docker 镜像 (Kaniko) +# ------------------------------------------------------------------ +build-docker-image: + stage: build-docker-image + image: + name: gcr.io/kaniko-project/executor:v1.23.0-debug + entrypoint: [""] + variables: + DOCKER_CONFIG: /kaniko/.docker + before_script: + - mkdir -p /kaniko/.docker + - cp "$DOCKER_AUTH_CONFIG" /kaniko/.docker/config.json + script: + - | + if [[ -n "$CI_COMMIT_TAG" ]]; then + IMAGE_TAG="${IMAGE_NAME}:${CI_COMMIT_TAG}" + else + IMAGE_TAG="${IMAGE_NAME}:${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}" + fi + /kaniko/executor \ + --context "${CI_PROJECT_DIR}" \ + --dockerfile "${CI_PROJECT_DIR}/infra/tencent/docker_ci/Dockerfile.root_db" \ + --destination "${IMAGE_TAG}" + needs: + - job: python-lint-format + optional: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: + - fst_data_pipeline/apps/root_db_api/**/* + - fst_data_pipeline/apps/root_db_api/pyproject.toml + - fst_data_pipeline/apps/root_db_api/uv.lock + - infra/tencent/docker_ci/Dockerfile.root_db + - pyproject.toml + - uv.lock + - if: $CI_COMMIT_TAG + - if: $CI_PIPELINE_SOURCE == "web" \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..fdcfcfd --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a041fe0 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# MB FST data production line infra code base + +this repo include all FST data production line code, include cloud infra scripts, micro service apps, pipelines code, tool scripts and sql. overall structure: + +``` +fst_data_pipeline/ +├── config # 统一配置管理 (YAML/ENV文件) +├── fst_data_pipeline # python source code package +│ ├── apps # 所有微服务应用 +│ ├── core # shared lib and utils +│ ├── pipelines # 所有数据自动化处理pipeline +├── infra # 基础设施定义 (Docker file/Terraform/K8s) +├── scripts # 非自动化的工具脚本 +├── pyproject.toml # 项目toml +├── code_format_all.sh # 代码格式化和lint检查脚本 +├── README.md +└── uv.lock +``` + diff --git a/code_format_all.sh b/code_format_all.sh new file mode 100644 index 0000000..9ec79e9 --- /dev/null +++ b/code_format_all.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +current_dir=$(dirname "$0") +cd "${current_dir}" || exit + +docker_version=$(docker -v | awk '{print $3}') +required_docker_version="24.0.2" +if [ "$(printf '%s\n' "$docker_version" "$required_docker_version" | sort -V | head -n1)" = "$docker_version" ]; then + echo "Error: Docker version $docker_version is less than ${required_docker_version}" + echo "Run 'sudo apt update && sudo apt upgrade docker -y' to upgrade docker to latest version" + exit 1 +fi + +format_src=./ +all_args=$* + +proxy="http://smtcig000004.cnrd.corpintra.net:3128/" +while [ $# -gt 0 ]; do + case "$1" in + --proxy=*) proxy="${1#*=}" ;; + esac + + shift +done + +if [ -z "${proxy+x}" ] && [ -n "$http_proxy" ]; then + proxy=${http_proxy} +fi + +docker_build_http_proxy= +docker_run_http_proxy= +if [ -n "$proxy" ]; then + docker_build_http_proxy="--build-arg HTTP_PROXY=${proxy} --build-arg HTTPS_PROXY=${proxy} --build-arg http_proxy=${proxy} --build-arg https_proxy=${proxy}" + docker_run_http_proxy="--env HTTP_PROXY=${proxy} --env HTTPS_PROXY=${proxy} --env http_proxy=${proxy} --env https_proxy=${proxy}" +fi + +IMAGE_WITH_TAG=$(python3 -c 'import yaml; print(yaml.safe_load(open("./infra/tencent/docker_ci/docker-compose.yaml"))["services"]["python-format"]["image"])') + +if ! docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "$IMAGE_WITH_TAG"; then + echo "Image $IMAGE_WITH_TAG does not exist. Building the image..." + cmd="docker compose -f ./infra/tencent/docker_ci/docker-compose.yaml build ${docker_build_http_proxy} python-format" + echo "${cmd}" + eval "${cmd}" +fi + +cmd="docker compose -f ./infra/tencent/docker_ci/docker-compose.yaml run ${docker_run_http_proxy} -u"$(id -u):$(id -g)" --rm --entrypoint=./infra/tencent/docker_ci/entrypoint_python_format.sh python-format --format-all --no-lint" +cmd="docker compose -f ./infra/tencent/docker_ci/docker-compose.yaml run ${docker_run_http_proxy} -u"$(id -u):$(id -g)" --rm --entrypoint=./infra/tencent/docker_ci/entrypoint_python_format.sh python-format --check --no-lint" +echo "${cmd}" +eval "${cmd}" diff --git a/fst_data_pipeline/__init__.py b/fst_data_pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/API.md b/fst_data_pipeline/apps/API.md new file mode 100644 index 0000000..152037f --- /dev/null +++ b/fst_data_pipeline/apps/API.md @@ -0,0 +1,676 @@ +ROOT DB API 使用文档 +======================== + +> 版本:1.0.0 + +--- + +目录 +---- + +* [Bags](#bags) +* [FST](#fst) +* [Geometry](#geometry) +* [Projects](#projects) +* [Tags](#tags) +* [Topics](#topics) + +--- + +Bags +---- + +### 1. 获取全部 Bags(按项目分组) +``` +GET /api/bags/all +``` +#### 请求示例 +``` +curl https:///api/bags/all +``` + +#### 成功响应 200 +```json +{ + "Project A": ["Bag 1", "Bag 2"], + "Uncategorized": ["Bag 3"] +} +``` + +#### 错误响应 500 +```json +{"error": "An error occurred"} +``` + +--- + +### 2. 批量查询 FST 节点 +``` +POST /api/bags/fst/nodes +``` +#### 请求体 +```json +["a.bag", "b.bag"] +``` + +#### 请求示例 +``` +curl -X POST https:///api/bags/fst/nodes \ + -H "Content-Type: application/json" \ + -d '["a.bag", "b.bag"]' +``` + +#### 成功响应 200 +```json +{ + "a.bag": [ + {"name": "node1", "start": 0, "end": 10}, + {"name": "node2", "start": 11, "end": 20} + ], + "b.bag": [ + {"name": "node3", "start": 21, "end": 30} + ] +} +``` + +#### 错误响应 +- `400`:Body must be a list of bag names +- `500`:An error occurred + +--- + +### 3. 获取生命周期信息 +``` +POST /api/bags/life_cycle +``` +#### 请求体 +```json +{"bag_names": ["a.bag", "b.bag"]} +``` + +#### 请求示例 +``` +curl -X POST https:///api/bags/life_cycle \ + -H "Content-Type: application/json" \ + -d '{"bag_names": ["a.bag", "b.bag"]}' +``` + +#### 成功响应 200 +```json +{ + "a.bag": { + "collect_time": "2025-07-16T00:00:00Z", + "decode_time": "2025-07-16T01:00:00Z", + "fst_index_time": "2025-07-16T02:00:00Z", + "mining_time": "2025-07-16T03:00:00Z", + "auto_annotate_time": "2025-07-16T04:00:00Z", + "manual_annotate_time": "2025-07-16T05:00:00Z", + "clone2dev_time": "2025-07-16T06:00:00Z" + }, + "b.bag": { } +} +``` + +--- + +### 4. 批量查询 Minerva 主记录(按 session_id) +``` +POST /api/bags/minerva +``` +#### 请求体 +```json +{"session_ids": ["p1.bag", "p2.bag"]} +``` + +#### 请求示例 +``` +curl -X POST https:///api/bags/minerva \ + -H "Content-Type: application/json" \ + -d '{"session_ids": ["p1.bag", "p2.bag"]}' +``` + +#### 成功响应 200 +```json +{ + "p1.bag": [{ + "session_id": "p1.bag", + "vin": "L123456789", + "platform": "Platform A", + "start_ts": 1626307200, + "end_ts": 1626314400, + "length": 7200, + "datetime": "2025-07-16T00:00:00Z", + "path": "/path/to/bag", + "converted_path": "/path/to/converted", + "gt_path": "/path/to/gt", + "reserved_json": {} + }], + "p2.bag": [] +} +``` + +--- + +### 5. 批量查询 Minerva 详情(按 bag name) +``` +POST /api/bags/minerva/detail +``` +#### 请求体 +```json +["p1.bag", "p2.bag"] +``` + +#### 请求示例 +``` +curl -X POST https:///api/bags/minerva/detail \ + -H "Content-Type: application/json" \ + -d '["p1.bag", "p2.bag"]' +``` + +#### 响应格式同 `/api/bags/minerva` + +--- + +### 6. 批量查询 Pangu 主记录 +``` +POST /api/bags/pangu +``` +#### 请求体 +```json +{"names": ["name1", "name2"]} +``` + +#### 请求示例 +``` +curl -X POST https:///api/bags/pangu \ + -H "Content-Type: application/json" \ + -d '{"names": ["name1", "name2"]}' +``` + +#### 成功响应 200 +```json +{ + "name1": [{ + "name": "Pangu 1", + "datetime": "2025-07-16T00:00:00Z", + "vehicle": "Vehicle A", + "raw_bag_path": "/path/to/bag", + "decoded_path": "/path/to/decoded", + "reserved_str": "Some reserved string", + "reserved_json": {} + }], + "name2": [] +} +``` + +--- + +### 7. 批量查询 Pangu 详情(按 bag name) +``` +POST /api/bags/pangu/detail +``` +#### 请求体 +```json +["bag1.bag", "bag2.bag"] +``` + +#### 请求示例 +``` +curl -X POST https:///api/bags/pangu/detail \ + -H "Content-Type: application/json" \ + -d '["bag1.bag", "bag2.bag"]' +``` + +#### 成功响应 200 +```json +{ + "bag1.bag": {"detail": "...", "other_property": "..."}, + "bag2.bag": {"detail": "...", "other_property": "..."} +} +``` + +--- + +### 8. 搜索 Main Pangu +``` +POST /api/bags/search/pangu +``` +#### 请求体 +```json +{ + "name": "Pangu 1", + "vehicle": "Vehicle A", + "datetime_from": "2025-07-16 00:00:00", + "datetime_to": "2025-07-16 23:59:59" +} +``` +字段均可选。 + +#### 请求示例 +``` +curl -X POST https:///api/bags/search/pangu \ + -H "Content-Type: application/json" \ + -d '{"name": "Pangu 1"}' +``` + +#### 成功响应 200 +```json +[ + { + "name": "Pangu 1", + "datetime": "2025-07-16T00:00:00Z", + "vehicle": "Vehicle A" + } +] +``` + +--- + +### 9. 聚合查询列表 +``` +POST /api/bags/search/aggregate?page=1&per_page=20 +``` + +#### 请求参数 + +参数来源说明: +- collect_start / collect_end:前端日期选择 +- vehicles:GET /api/bags/vehicles +- sw_version / hw_version:GET /api/bags/versions +- topics:GET /api/topics/all +- tags:GET /api/tags/all +- gt_names:GET /api/gt/types +- fst_level1:GET /api/fst/print_tree(取 level=1 的节点名;或用 /api/fst/all_nodes 过滤根节点子节点) + +Query 参数: +- page: 页码,默认 1 +- per_page: 每页数量,默认 20,最大 100 +- debug_sql: 是否打印 SQL(仅日志输出),默认 false + +Body 字段(均可选): +- collect_start: 起始日期,基于 MainPangu.datetime,格式 YYYYMMDD,例如 "20250101" +- collect_end: 结束日期,基于 MainPangu.datetime,格式 YYYYMMDD,例如 "20250131" +- bag_name: bag 名称模糊匹配(支持 * 通配),例如 "xxx*2025*" +- gt_names: GT 名称列表(OR 语义) +- fst_level1: FST 一级节点名称(包含其子孙节点) +- vehicles: 车号列表(OR 语义,精确匹配) +- sw_version: 软件版本,支持 * 通配,例如 "v1.*" 或 "*2024*" +- hw_version: 硬件版本,支持 * 通配 +- topics: Topic 名称列表(OR 语义) +- tags: Tag 名称列表(OR 语义) + +#### 请求体示例 +```json +{ + "collect_start": "20250101", + "collect_end": "20250131", + "bag_name": "xxx*2025*", + "gt_names": ["gt_a", "gt_b"], + "fst_level1": "root_fst", + "vehicles": ["PL061763"], + "sw_version": "v1.*", + "hw_version": "h1.*", + "topics": ["topic_a", "topic_b"], + "tags": ["tag_a", "tag_b"] +} +``` + +#### 请求示例 +``` +curl -X POST "https:///api/bags/search/aggregate?page=1&per_page=20&debug_sql=true" \ + -H "Content-Type: application/json" \ + -d '{"collect_start":"20250101","collect_end":"20250131","bag_name":"xxx*2025*","gt_names":["gt_a"],"fst_level1":"root_fst","vehicles":["PL061763"],"sw_version":"v1.*","hw_version":"h1.*","topics":["topic_a"],"tags":["tag_a"]}' +``` + +#### 响应字段 +- items: 列表 + - bag_id: bag_list.id + - bag_name: bag 名称 + - bag_path: 原始 bag 路径(空则返回 "") + - data_path: 解析后数据路径(空则返回 "") + - datetime: MainPangu.datetime(YYYY-MM-DD HH:MM:SS,空则返回 "") + - collect_time: BagLifecycle.collect_time(YYYY-MM-DD HH:MM:SS,空则返回 "") + - vehicle: 车号(空则返回 "") + - sw_version: 软件版本(空则返回 "") + - hw_version: 硬件版本(空则返回 "") + - mviz_link: 可视化链接(预留,当前为空字符串) + - mbviz_link: 可视化链接(预留,当前为空字符串) + - fst: FST 路径数组(可能多条,level1 对应业务一级节点,level0(root) 不返回) + - level1/level2/level3/level4: 对应层级节点对象,缺失则为 {} + - gt: 关联 GT 列表(包含 id/name/type/path/comment) + - topics: 关联 Topic 列表(包含 id/name/type) + - tags: 关联 Tag 列表(包含 id/name/type/creator) +- page: 当前页 +- per_page: 每页数量 +- total: 总数 + +#### 成功响应 200 +```json +{ + "items": [ + { + "bag_id": 123, + "bag_name": "xxx.bag", + "bag_path": "/path/to/bag", + "data_path": "/path/to/data", + "datetime": "2025-07-16 00:00:00", + "collect_time": "2025-07-16 00:00:00", + "vehicle": "PL061763", + "sw_version": "v1.2.3", + "hw_version": "h1.0", + "mviz_link": "", + "mbviz_link": "", + "fst": [ + { + "level1": {"id": 1, "name": "L1"}, + "level2": {"id": 2, "name": "L2"}, + "level3": {"id": 3, "name": "L3"}, + "level4": {"id": 4, "name": "L4"} + } + ], + "gt": [ + {"id": 10, "name": "gt_a", "type": "3DBox", "path": "/path/to/gt", "comment": "备注"} + ], + "topics": [ + {"id": 20, "name": "topic_a", "type": "sensor_msgs/PointCloud2"} + ], + "tags": [ + {"id": 30, "name": "tag_a", "type": "user", "creator": "alice"} + ] + } + ], + "page": 1, + "per_page": 20, + "total": 123 +} +``` + +--- + +### 10. 获取可用版本列表 +``` +GET /api/bags/versions +``` + +#### 请求示例 +``` +curl https:///api/bags/versions +``` + +#### 成功响应 200 +```json +{ + "sw_versions": ["v1.0", "v1.1", "v2.0"], + "hw_versions": ["h1.0", "h2.0"] +} +``` + +--- + + +### 11. 获取可用车号列表 +``` +GET /api/bags/vehicles +``` + +#### 请求示例 +``` +curl https:///api/bags/vehicles +``` + +#### 成功响应 200 +```json +{ + "vehicles": ["PL061763", "PL061764"] +} +``` + +--- + +### 12. 批量查询 Tags +``` +POST /api/bags/tags +``` +#### 请求体 +```json +["a.bag", "b.bag"] +``` + +#### 成功响应 200 +```json +{ + "a.bag": [ + {"name": "tag1", "type": "type1"}, + {"name": "tag2", "type": "type2"} + ], + "b.bag": [ + {"name": "tag3", "type": "type3"} + ] +} +``` + +--- + +### 13. 批量查询 Topics +``` +POST /api/bags/topics +``` +#### 请求体 +```json +["a.bag", "b.bag"] +``` + +#### 成功响应 200 +```json +{ + "a.bag": [ + {"name": "topic1", "type": "type1"}, + {"name": "topic2", "type": "type2"} + ], + "b.bag": [ + {"name": "topic3", "type": "type3"} + ] +} +``` + +--- + +FST +--- + +### 1. 批量获取 FST → Bags 映射 +``` +POST /api/fst/bags/nodes +``` +#### 请求体 +```json +["fst1", "fst2"] +``` + +#### 成功响应 200 +```json +{ + "fst1": ["Bag1", "Bag2"], + "fst2": ["Bag3"] +} +``` + +--- + +### 2. 获取指定 FST 及其子节点的 Bags +``` +GET /api/fst/bags/path/{name} +``` +#### 请求示例 +``` +curl https:///api/fst/bags/path/fst1 +``` + +#### 成功响应 200 +```json +{ + "fst1": {"bags": ["Bag1", "Bag2"]}, + "fst1_child": {"bags": ["Bag3"]} +} +``` + +#### 错误响应 404:Specified FST not found + +--- + +### 3. 获取 FST 树形结构(纯文本) +``` +GET /api/fst/print_tree +``` +#### 成功响应 200 +``` +
+Your FST tree output here
+
+``` + +--- + +### 4. 获取单个 FST 详情 +``` +GET /api/fst/{name} +``` +#### 请求示例 +``` +curl https:///api/fst/fst1 +``` + +#### 成功响应 200 +```json +{ + "name": "fst1", + "parent_node": "Parent FST Name", + "linked_bags_sum": 5 +} +``` + +#### 错误响应 404:Specified FST not found + +--- + +Geometry +-------- + +### 批量获取 Rosbag 的 Geometry 信息 +``` +POST /api/geometry/ +``` +#### 请求体 +```json +{"rosbag_names": ["rosbag1", "rosbag2"]} +``` + +#### 成功响应 200 +```json +{ + "rosbag1": [[12.345678, 12.345678, 0.0]], + "rosbag2": [[98.765432, 98.765432, 0.0]] +} +``` + +--- + +Projects +-------- + +### 获取所有项目 +``` +GET /api/projects/all +``` +#### 成功响应 200 +```json +[ + {"id": 1, "name": "Project Name"} +] +``` + +--- + +Tags +---- + +### 1. 获取全部 Tags +``` +GET /api/tags/all +``` +#### 成功响应 200 +```json +[ + { + "name": "Data Quality", + "type": "quality", + "creator": "alice@example.com" + } +] +``` + +--- + +### 2. 根据 Tag 名称获取关联 Bags +``` +GET /api/tags/bags/{tag_name} +``` +#### 请求示例 +``` +curl https:///api/tags/bags/Data%20Quality +``` + +#### 成功响应 200 +```json +{"bags": ["bag_2024_07"]} +``` + +--- + +### 3. 根据 Creator 获取 Tags +``` +GET /api/tags/creators/{creator} +``` +#### 请求示例 +``` +curl https:///api/tags/creators/alice@example.com +``` + +#### 成功响应 200 +```json +{"tags": ["Sales", "Data Quality"]} +``` + +--- + +Topics +------ + +### 1. 获取全部 Topics +``` +GET /api/topics/all +``` +#### 成功响应 200 +```json +[ + {"name": "Topic Name", "type": "Topic Type"} +] +``` + +--- + +### 2. 根据 Topic 名称获取关联 Bags +``` +GET /api/topics/bags/{topic_name} +``` +#### 请求示例 +``` +curl https:///api/topics/bags/topic1 +``` + +#### 成功响应 200 +```json +{"name": ["Bag Name"]} +``` + +--- diff --git a/fst_data_pipeline/apps/README.md b/fst_data_pipeline/apps/README.md new file mode 100644 index 0000000..b4ff4c7 --- /dev/null +++ b/fst_data_pipeline/apps/README.md @@ -0,0 +1,2 @@ +# Micro Service apps +Backend micro service application for fst data production line. \ No newline at end of file diff --git a/fst_data_pipeline/apps/__init__.py b/fst_data_pipeline/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/mta_manage_system/README.md b/fst_data_pipeline/apps/mta_manage_system/README.md new file mode 100644 index 0000000..8925389 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/README.md @@ -0,0 +1,77 @@ +# 项目概述: + MTA(multi trip aggregation) 管理系统是一个用于MTA数据处理任务的综合性管理系统,它包含了数据索引和下载,数据预处理和瓦片生成,路口轨迹可视化生成,html筛选,数据包下载和分类以及MTA数据处理任务的管理等功能。 +# 项目结构: +mta_manage_system/ +├── db/ +│ └── init.sql # 数据库初始化脚本 +├── docker/ +│ └── Dockerfile # Docker 镜像构建文件 +├── models/ +│ ├── __init__.py # 模型初始化文件 +│ ├── sessionrecord.py # 会话记录模型 +│ └── taskstatus.py # 任务状态模型 +├── services/ +│ └── long_running_tasks.py # 长时间运行任务服务 +├── static/ +│ ├── css/ +│ │ ├── bootstrap.min.css # Bootstrap CSS 文件 +│ │ ├── bootstrap-icons.css # Bootstrap Icons CSS 文件 +│ │ ├── detail.css # 详情页面样式文件 +│ │ └── sweetalert2.css # SweetAlert2 样式文件 +│ ├── images/ # 图片资源目录 +│ └── js/ +│ ├── bootstrap.bundle.min.js # Bootstrap JavaScript 文件 +│ ├── detail.js # 详情页面 JavaScript 文件 +│ ├── injector.js # 注入器 JavaScript 文件 +│ ├── jquery-3.7.1.min.js # jQuery 库文件 +│ └── sweetalert2.js # SweetAlert2 库文件 +├── templates/ +│ ├── detail.html # 详情页面模板 +│ └── list.html # 列表页面模板 +├── utils/ # 工具类 +│ ├── docker_tool.py # 操作容器工具类 +│ ├── extract_package_tool.py # 通过瓦片获取包信息 +│ ├── log_tool.py # 日志工具类 +│ ├── path_tool.py # 路径处理工具类 +│ └── task_queue_tool.py # 任务管理工具类 +├── .env # 环境变量配置文件 +├── app.py # 应用入口文件 +├── config.py # 配置文件 +├── extensions.py # 扩展模块文件 +├── pyproject.toml # Python 项目配置文件 +└── README.md # 项目说明文件 +css 静态资源版本说明: +[bootstrap-icons.css](static/css/bootstrap-icons.css) v1.11.0 +[bootstrap.min.css](static/css/bootstrap.min.css) v4.3.1 +[sweetalert2.css](static/css/sweetalert2.css) v11.26.3 +js 静态资源版本说明: +[bootstrap.bundle.min.js](static/js/bootstrap.bundle.min.js) v4.3.1 +[jquery-3.7.1.min.js](static/js/jquery-3.7.1.min.js) v3.7.1 +[sweetalert2.js](static/js/sweetalert2.js) v11.26.3 +# 功能模块 +## 数据库 +db/目录下包含 init.sql脚本,用于初始化数据库表结构和基础数据。数据库选用了mysql 数据库。 +## 模型层 +models/目录定义了与数据库交互的模型类,如 sessionrecord.py和 taskstatus.py分别对应会话记录和任务状态的数据库操作。 +## 服务层 +services/目录中的 long_running_tasks.py处理长时间运行的任务逻辑,提供任务调度和管理功能。 +## 静态资源 +static/目录存放项目的静态资源,包括 CSS 样式文件、JavaScript 脚本文件和图片资源。 +## 模板 +templates/目录包含了前端页面的 HTML 模板文件,如 detail.html和 list.html。 +## 入口和应用配置 +app.py是应用的入口点,负责启动 Web 服务。config.py存放项目的配置信息,extensions.py定义和初始化项目所需的扩展模块。 + +# 运行指南 +1,先启动mysql 数据库,执行初始化sql 脚本[init.sql](db/init.sql) +2,执行 uv sync +3,执行 python3 app.py +项目启动会从配置文件[.env](.env)中获取配置参数,其中数据密码为你所使用的数据库账户和密码 + + +# 技术栈 + +后端:Python, Flask +数据库:MySQL +前端:HTML, CSS, JavaScript, Bootstrap, SweetAlert2 +容器化:Docker diff --git a/fst_data_pipeline/apps/mta_manage_system/__init__.py b/fst_data_pipeline/apps/mta_manage_system/__init__.py new file mode 100644 index 0000000..4fc5574 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/__init__.py @@ -0,0 +1,23 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from config import config + + +from extensions import db # 统一导入 db 实例 + + +def create_app(): + app = Flask(__name__) + + # 配置 + app.config["SQLALCHEMY_DATABASE_URI"] = config.SQLALCHEMY_DATABASE_URI + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + # 初始化扩展 + db.init_app(app) + + # 注册蓝图 + # from routes import main + # app.register_blueprint(main) + + return app diff --git a/fst_data_pipeline/apps/mta_manage_system/app.py b/fst_data_pipeline/apps/mta_manage_system/app.py new file mode 100644 index 0000000..c6a5f43 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/app.py @@ -0,0 +1,829 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- +""" +@Project :bag_filter +@File :app.py +@Author :zuowe +@Date :2025/10/20 10:36 +@Des : +""" + +import os +from flask import Flask, render_template, send_from_directory, request, jsonify +from datetime import datetime +import json +import logging + +from utils.docker_tool import docker_tool +from sqlalchemy import select, func +from pathlib import Path +from config import config, logger +from utils.log_tool import setup_logging +from utils.extract_package_tool import extract_package_names_from_html +from utils.task_queue_tool import task_queue +from utils.path_tool import PathTool +from extensions import db # 统一导入 db 实例 +from models.sessionrecord import SessionRecord +from models.taskstatus import TaskStatus +from services.long_running_tasks import LongRunningTasks +from __init__ import create_app + +# 创建应用 +app = create_app() + + +# 数据库连接 +# database_url = config.SQLALCHEMY_DATABASE_URI + +BATH_ROOT = config.BATH_ROOT +IMAGE_FOLDER = config.IMAGE_FOLDER +PAGES_DIR = config.PAGES_DIR +SCRIPT_PATH = config.SCRIPT_PATH +HTML_DIR = config.HTML_DIR + +OUTPUT_PIC_PATH = config.OUTPUT_PIC_PATH +OUTPUT_PIC_NAME = config.OUTPUT_PIC_NAME +OUTPUT_RESULT_PATH = config.OUTPUT_RESULT_PATH +# +MAX_ALLOW_RUNNING_MTA_TASK = config.MAX_ALLOW_RUNNING_MTA_TASK +MTA_IMAGE = config.MTA_IMAGE +MTA_IMNAGE_VERSION = config.MTA_IMNAGE_VERSION + +CONTAINER_OUTER_PATH = config.CONTAINER_OUTER_PATH +ENV_PARAM_KEY = config.ENV_PARAM_KEY +ENV_PARAM_VALUE = config.ENV_PARAM_VALUE +MTA_SCRIPT = config.MTA_SCRIPT + + +# 允许的扩展名 +ALLOWED_EXTS = config.ALLOWED_EXTS + +# 每页最大条数限制 +MAX_PER_PAGE = config.MAX_PER_PAGE +DEFAULT_PER_PAGE = config.MAX_PER_PAGE + +# 配置日志 + +logger = setup_logging("logs/app.log", logging.INFO) + + +# ===== 数据库配置 - 使用 config 中的统一配置 ===== +# 在 config.py 中添加数据库配置 +app.config["SQLALCHEMY_DATABASE_URI"] = config.SQLALCHEMY_DATABASE_URI +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + +@app.route("/") +def list_pages(): + return render_template("list.html") + + +# 列表数据分页查询 +@app.route("/api/items") +def api_items(): + # 1) 分页参数 + try: + page = int(request.args.get("page", 1)) + per_page = int(request.args.get("per_page", DEFAULT_PER_PAGE)) + if page < 1: + page = 1 + if per_page < 1 or per_page > MAX_PER_PAGE: + per_page = DEFAULT_PER_PAGE + except ValueError: + page = 1 + per_page = DEFAULT_PER_PAGE + + # 2) 过滤参数 + html_name = request.args.get("html_name", "").strip() + central_session = request.args.get("central_session", "").strip() + mta_task_status = request.args.get("mta_task_status", "").strip() + is_filter_bag = request.args.get("is_filter_bag", "") + is_run_mta = request.args.get("is_run_mta", "") + logger.info( + f"is_filter_bag 值为:{is_filter_bag} ,is_run_mta 值为:{is_run_mta} " + ) + # 将字符串转换为布尔值 + + if request.args.get("is_filter_bag", "").strip().lower() == "true": + is_filter_bag = True + elif request.args.get("is_filter_bag", "").strip().lower() == "false": + is_filter_bag = False + else: + is_filter_bag = request.args.get("is_filter_bag", "").strip() + if request.args.get("is_run_mta", "").strip().lower() == "true": + is_run_mta = True + elif request.args.get("is_run_mta", "").strip().lower() == "false": + is_run_mta = False + else: + is_run_mta = request.args.get("is_run_mta", "").strip() + + # 3) 构建 COUNT 查询(与主查询条件保持一致) + stmt_count = select(func.count(SessionRecord.id)).where( + (SessionRecord.html_name.like(f"%{html_name}%")) if html_name else True, + (SessionRecord.central_session.like(f"%{central_session}%")) + if central_session + else True, + # 处理 is_filter_bag + (SessionRecord.is_filter_bag == True) + if is_filter_bag == True + else (SessionRecord.is_filter_bag == False) + if is_filter_bag == False + else True, + # 处理 is_run_mta + (SessionRecord.is_run_mta == True) + if is_run_mta == True + else (SessionRecord.is_run_mta == False) + if is_run_mta == False + else True, + # 处理 mta_task_status + (SessionRecord.mta_task_status == mta_task_status) + if mta_task_status and mta_task_status.strip() # 不为空且非空白字符串 + else True, + ) + total = db.session.scalar(stmt_count) or 0 + + # 4) 构建分页数据查询(在 select 上链式调用 offset/limit) + stmt_data = ( + select(SessionRecord) + .where( + (SessionRecord.html_name.like(f"%{html_name}%")) if html_name else True, + (SessionRecord.central_session.like(f"%{central_session}%")) + if central_session + else True, + # 处理 is_filter_bag + (SessionRecord.is_filter_bag == True) + if is_filter_bag == True + else (SessionRecord.is_filter_bag == False) + if is_filter_bag == False + else True, + # 处理 is_run_mta + (SessionRecord.is_run_mta == True) + if is_run_mta == True + else (SessionRecord.is_run_mta == False) + if is_run_mta == False + else True, + # 处理 mta_task_status + (SessionRecord.mta_task_status == mta_task_status) + if mta_task_status and mta_task_status.strip() # 不为空且非空白字符串 + else True, + ) + .order_by(SessionRecord.id.asc()) # 建议使用索引字段排序 + .offset((page - 1) * per_page) + .limit(per_page) + ) + + rows = db.session.execute(stmt_data).scalars().all() + logger.info(it.to_dict() for it in rows) + + # 5) 计算分页元信息 + total_pages = (total + per_page - 1) // per_page + has_prev = page > 1 + has_next = page < total_pages + + # 7) 返回 JSON + return jsonify({ + "items": [it.to_dict() for it in rows], + "page": page, + "per_page": per_page, + "total": total, + "total_pages": total_pages, + "has_prev": has_prev, + "has_next": has_next, + }) + + +@app.route("/api/syncdata", methods=["GET"]) +def sync_data(): + try: + bag_folder = request.args.get("bag_folder") + html_folder = request.args.get("html_folder") + logger.info( + f"获取到的参数:bag_folder = {bag_folder},html_folder = {html_folder} " + ) + # html_folder ="selected_8to9" + # 1. 获取/data/html目录下所有html文件 + html_dir = PathTool.join(BATH_ROOT, html_folder) + if not os.path.exists(html_dir): + return jsonify({"error": "HTML directory not found"}), 404 + + html_files = [f for f in os.listdir(html_dir) if f.endswith(".html")] + # logger.info(html_files) + # 2. 获取数据库中已有的html_name列表 + existing_records = ( + SessionRecord.query.filter(SessionRecord.html_folder == html_folder) + .with_entities(SessionRecord.html_name) + .all() + ) + existing_names = {r[0] for r in existing_records} + + # 3. 比对并插入新记录 + new_count = 0 + for filename in html_files: + if filename not in existing_names: + new_record = SessionRecord( + html_name=filename, + html_folder=html_folder, + bag_folder=bag_folder, + central_session="", # 默认值 + query_sessions="", # 默认空JSON数组 + selected_image_url="null", + is_filter_bag=False, + is_run_mta=False, + run_mta_result="", + remark="自动同步添加", + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + db.session.add(new_record) + new_count += 1 + + db.session.commit() + + return jsonify({ + "status": "success", + "scanned_files": len(html_files), + "new_records_added": new_count, + }) + + except Exception as e: + db.session.rollback() + print(e) + return jsonify({"error": str(e)}), 500 + + +# 接口:GET /api/items/ +# ======================= +@app.route("/api/items/", methods=["GET"]) +def get_item(id: int): + # db: Session = SessionLocal() + try: + item = db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + if not item: + return jsonify({"error": "记录不存在", "id": id}), 404 + + data = row2dict(item) + return jsonify(status=True, result=data) + except Exception as e: + app.logger.exception("查询接口异常") + return jsonify({"error": "服务器内部错误", "detail": str(e)}), 500 + + +# ====================== +# 接口:POST /api/items (新增) +# PUT /api/items/ (更新) +# ====================== +@app.route("/api/saveitems", methods=["POST"]) +def save_or_update_item(id=None): + try: + data = request.get_json() + if not data: + return jsonify({"error": "请求体必须为 JSON"}), 400 + + # 1) 获取必填字段校验(可根据需求调整) + id = data.get("id") + html_name = data.get("html_name") + central_session = data.get("central_session") + query_session = data.get("query_session") + if not html_name or not central_session: + return jsonify({"error": "html_name 和 central_session 为必填项"}), 400 + logger.info(f"查询到的 query_session 为:{query_session}") + # 2) 处理布尔字段:转为 Python Boolean 或 None + is_filter_bag = str_to_bool(data.get("is_filter_bag")) + is_run_mta = str_to_bool(data.get("is_run_mta")) + success = str_to_bool(data.get("success")) + + # 3) 判断是新增还是更新 + if id is None: + # 🟢 新增记录 + item = SessionRecord( + html_name=html_name, + central_session=central_session, + query_sessions=data.get("query_session"), # 可为字符串或后续转 JSON + selected_image_url=data.get("selected_image_url"), + is_filter_bag=is_filter_bag, + is_run_mta=is_run_mta, + run_mta_result=data.get("run_mta_result"), # 可为 JSON 字符串或对象 + remark=data.get("remark"), + success=success, + ) + + db.session.add(item) + db.session.flush() # 为了获取自增 id(如有需要) + db.session.commit() + + return jsonify({ + "status": "success", + "message": "记录新增成功", + "data": { + **item.__dict__, + "_sa_instance_state": None, + }, # 去掉内部属性 + }), 201 + else: + # 🔵 更新记录 + item = ( + db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + ) + if not item: + return jsonify({"error": f"记录不存在,id={id}"}), 404 + + # 更新字段 + item.html_name = html_name + item.central_session = central_session + item.query_sessions = data.get("query_session") + item.selected_image_url = data.get("selected_image_url") + item.is_filter_bag = is_filter_bag + item.is_run_mta = is_run_mta + item.run_mta_result = data.get("run_mta_result") + item.remark = data.get("remark") + # item.success = success + item.updated_at = func.now() # 可选,如果模型没有自动更新 + + db.session.commit() + return jsonify({"status": "success", "message": "记录更新成功"}) + + except Exception as e: + db.rollback() + app.logger.exception("保存/更新接口异常") + return jsonify({"error": "服务器内部错误", "detail": str(e)}), 500 + + +@app.route("/detail////") +def filterbag_detail(html_name, bag_folder, html_folder, id): + # 这里接受url 传参 ,将页面重定向到 详情选择页面 ,并将参数和查询的数据返回到详情页面 + # file_path = os.path.join(PAGES_DIR, html_name) + print(id) + # html_folder = "selected_8to9" + # html_name = "15_27321_13493_merged_intersections_17_100.0m.html" + return render_template( + "detail.html", + html_name=html_name, + bag_folder=bag_folder, + html_folder=html_folder, + abslute_path=BATH_ROOT, + id=id, + ) + + +# 获取图片 在页面展示 +@app.route("/api/getimage////") +def api_getimages(imagename, bagname, htmlname, bagfolder): + # 安全校验:只允许访问 pages 目录下的 HTML + # 安全拼接并限制到白名单目录 + # bagname = "PL162802_event_all_time_event_20250628-131016_0.bag.dir" + logger.info(f"image aaa path : imagename: {imagename}") + base = PathTool.join(BATH_ROOT, bagfolder, htmlname, bagname, IMAGE_FOLDER) + logger.info(f"image aaa path :{base} , bagname: {bagname}") + + # 如果找到图片则返回图片数据 + if imagename in os.listdir(base): + return send_from_directory(base, imagename) + + return jsonify({"error": "Not found or access denied"}) + + +# 获取图片 在页面展示 +@app.route("/api/getmtaresult/") +def api_getmtaresult(id): + # 安全校验:只允许访问 pages 目录下的 HTML + # 安全拼接并限制到白名单目录 + item = db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + bagfolder = item.bag_folder + html_name = item.html_name + htmlname = html_name.replace(".html", "") + bagname = item.central_session + imagename = item.run_mta_result + + base = PathTool.join(BATH_ROOT, bagfolder, htmlname, bagname, OUTPUT_PIC_PATH) + logger.info(f"image aaa path :{base} , bagname: {bagname}") + + # 如果找到图片则返回图片数据 + if imagename in os.listdir(base): + return send_from_directory(base, OUTPUT_PIC_NAME) + + return jsonify({"error": "Not found or access denied"}) + + +# 获取图片 在页面展示 +@app.route("/api/getimage") +def api_getimage(): + bagname = request.args.get("bagname") + imageIndex = request.args.get("imageIndex") + htmlname = request.args.get("htmlname") + bagfolder = request.args.get("bagfolder", "") + + logger.info(f"htmlname的地址为:{str(htmlname)},bagfolder :{bagfolder} ") + # bagname = "PL162802_event_all_time_event_20250628-131016_0.bag.dir" + if not bagname: + return jsonify(status=False, message="missing bagname"), 400 + if imageIndex is None: + return jsonify(status=False, message="missing imageIndex"), 400 + root = Path(BATH_ROOT) / bagfolder / htmlname / bagname / IMAGE_FOLDER + image_list = sorted(root.glob("*.jpg")) + logger.info(f"拼接后的地址为:{str(root)}") + + imageIndex = int(imageIndex.strip()) + # 用户使用习惯的索引比 list 索引大1 ,所以这里要减去1 + imageIndex -= 1 + # 通过index 获取 list 中选择的图片 + imagename = str(image_list[imageIndex]) + payload = {"size": len(image_list), "imagename": imagename} + return jsonify(status=True, result=payload) + + +@app.route("/api/options//") +def api_pages(html_name, html_folder): + logger.info(f"获取到的参数: html_name = {html_name},html_folder = {html_folder} ") + html_dir = PathTool.join(BATH_ROOT, html_folder) + # 可在此处读取html文件 包 内容用于展示 + file_path = os.path.join(html_dir, html_name) + + # 按“键名全小写”的约定返回,前端可通过 res.ok 与 res.data 访问 + bagset = extract_package_names_from_html(file_path, logger) + + data = [ + {"value": k, "label": k} + for k in sorted(bagset) # 如需保持原集合无序,可去掉 sorted() + ] + + return jsonify(status=True, result=data) + + +@app.route("/api/getimagedata") +def get_data(): + bagname = request.args.get("bagname") + htmlname = request.args.get("htmlname", "") + bagfolder = request.args.get("bagfolder", "") + + logger.info(f"获取到的参数 htmlname :{htmlname}") + path = PathTool.join(BATH_ROOT, bagfolder, htmlname, bagname, IMAGE_FOLDER) + p = Path(path) + image_list = sorted(p.glob("*.jpg")) + logger.info(len(image_list)) + imagename = str(image_list[0]) + # 构造返回:size 为列表大小;first 为第一个元素或 null + payload = { + "size": len(image_list), + "imagename": imagename, + "index": str(image_list[0]) + if image_list + else None, # 非 JSON 类型在此统一转 str + } + return jsonify(status=True, result=payload) + + +def fmt_date(dt: datetime) -> str: + return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "-" + + +def to_camel_case(snake: str) -> str: + parts = snake.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + +def row2dict(row) -> dict: + d = {} + for col in row.__table__.columns: + key = to_camel_case(col.name) + val = getattr(row, col.name) + if isinstance(val, datetime): + d[key] = fmt_date(val) + elif col.name in ("querySessions", "runMtaResult") and val is not None: + # 若数据库字段为 JSON/Text,这里做一次安全反序列化 + try: + d[key] = json.loads(val) if isinstance(val, str) else val + except Exception: + d[key] = val + else: + d[key] = val + return d + + +# ====================== +# 工具函数:字符串 "true"/"false" 转布尔值 +# ====================== +def str_to_bool(s): + if s is None: + return None + if s == "true": + return True + if s == "false": + return False + return None + + +# ===== 提交接口:写入 MySQL ===== +@app.route("/api/submit", methods=["POST"]) +def submit(): + try: + # 1) 解析 JSON + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "请求体不是合法的 JSON"}), 400 + + # 2) 兼容两种可能的前端结构 + # 结构A(当前表单字段): central_session, query_sessions, selected_image_url + # 结构B(你示例 alert 用到): tags(数组), category + + central_session = data.get("category") + query_sessions = data.get("tags") + bag_folder = data.get("bag_folder") + html_name = data.get("html_name") + html_name_folder = html_name.replace(".html", "") + logger.info(bag_folder) + bagpath = PathTool.join(BATH_ROOT, bag_folder, html_name_folder) + logger.info("拼接的数据包根路径为:" + bagpath) + new_query_sessions = list( + map(lambda x: PathTool.join(bagpath, x), query_sessions) + ) + logger.info(f"query_sessions:{query_sessions}") + query_sessions = ",".join(query_sessions) + + central_session_str = PathTool.join(bagpath, central_session) + query_sessions_str = " ".join(new_query_sessions) + logger.info( + f"query_sessions_str:{query_sessions_str}, central_session_str : {central_session_str}" + ) + + # 2) 写入数据库 + + id = data.get("id") + logger.info(f"查询到的数据ID : {id}") + # 🔵 更新记录 + # 查询记录 + item = db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + + if not item: + return jsonify({"error": f"记录不存在,html_name={html_name}"}), 404 + + # 动态更新字段(仅更新传入的字段) + # 仅更新指定字段 + if central_session is not None: + item.central_session = central_session + if query_sessions is not None: + item.query_sessions = query_sessions + + # 更新是否筛选包字段信息 是否跑 mta 需要看弹出框 + item.is_filter_bag = True + + # item.is_run_mta = True + + # 更新修改时间 + item.updated_at = func.now() + + db.session.commit() + + payload = { + "status": "success", + "message": "数据提交成功", + "data": { + "central_session": central_session_str, + "query_sessions": query_sessions_str, + }, + } + + return jsonify(status=True, result=payload) + except Exception as e: + db.session.rollback() + app.logger.exception("提交失败") + return jsonify({"error": f"服务器异常:{str(e)}"}), 500 + + +# +# 提交 task 方法 +# + + +# 查看 容器状态 +@app.route("/api/gettasksstatus", methods=["GET"]) +def get_tasks_status(): + """查看队列状态""" + status = task_queue.get_queue_status() + + print(f"📊 当前队列状态:") + print(f" 最大并发任务数: {status['max_concurrent_tasks']}") + print(f" 正在执行的任务数: {status['running_count']}") + print(f" 等待执行的任务数: {status['pending_count']}") + print(f" 已完成的任务数: {status['successful_count']}") + print(f" 失败的任务数: {status['failed_count']}") + print(f" 取消的任务数: {status['cancelled_count']}") + + return jsonify({ + "max_concurrent_tasks": status["max_concurrent_tasks"], + "running_count": status["running_count"], + "pending_count": status["pending_count"], + "successful_count": status["successful_count"], + "failed_count": status["failed_count"], + "cancelled_count": status["cancelled_count"], + }) + + +# 查看 容器状态 +@app.route("/api/stoptask", methods=["GET"]) +def stop_tasks(): + """查看队列状态""" + id = request.args.get("id") + item = db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + if not item: + return jsonify({ + "success": True, + "data": "", + "message": "系统异常,请联系管理员", + }), 500 + # mta_task_id = item.mta_task_id + # result = task_queue.cancel_task(mta_task_id) + container_name = f"MTA-{id}" + stop_result = docker_tool.stop_container_by_name(container_name) + item.mta_task_status = TaskStatus.FAILED.value + item.mta_task_id = None + item.run_mta_result = None + item.updated_at = func.now() + db.session.commit() + return jsonify(stop_result) + + +@app.route("/api/reloadtask") +def reloadTask(): + id = request.args.get("id") + item = db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + if not item: + return jsonify({ + "success": False, + "data": "", + "message": "系统异常,请联系管理员", + }), 200 + status = task_queue.get_queue_status() + running_count = status["running_count"] + bagfolder = item.bag_folder + + html_name = item.html_name + html_name = html_name.replace(".html", "") + + category = item.central_session + query_sessions = item.query_sessions + if not category or not query_sessions: + return jsonify({ + "success": False, + "data": "", + "message": "central_session 或者 query_sessions不能为空", + }), 200 + central_session = PathTool.join(BATH_ROOT, html_name, category) + querySessionCommand = getQuerySessionCommand(query_sessions, html_name) + run_mta_command_result = runmtacommandFun( + bagfolder, html_name, central_session, querySessionCommand, category, id + ) + if run_mta_command_result: + return jsonify(run_mta_command_result), 200 + else: + return jsonify(run_mta_command_result), 500 + + +@app.route("/api/runmtacommand") +def runmtacommand(): + status = task_queue.get_queue_status() + bagfolder = request.args.get("bag_folder") + command = request.args.get("command") + html_name = request.args.get("html_name", "") + html_name = html_name.replace(".html", "") + central_session = request.args.get("central_session", "") + category = request.args.get("category", "") + query_sessions = request.args.get("query_sessions", "") + + run_mta_command_result = runmtacommandFun( + bagfolder, html_name, central_session, query_sessions, category, id + ) + if run_mta_command_result: + return jsonify(run_mta_command_result), 200 + else: + return jsonify(run_mta_command_result), 500 + + +def getQuerySessionCommand(querySession, html_name): + querysessionList = [ + querySession.strip() + for querySession in querySession.split(",") + if querySession.strip() + ] + paths = [PathTool.join(BATH_ROOT, html_name, item) for item in querysessionList] + return " ".join(paths) + + +def runmtacommandFun( + bagfolder, html_name, central_session, query_sessions, category, id +): + run_mta_command_result = {"success": True, "data": "", "message": "任务提交成功"} + status = task_queue.get_queue_status() + running_count = status["running_count"] + html_name = html_name.replace(".html", "") + result_file = PathTool.join( + BATH_ROOT, bagfolder, html_name, category, OUTPUT_RESULT_PATH + ) + command = f"{MTA_SCRIPT} --central_session {central_session} --query_session {query_sessions}" + # command = "" + id = request.args.get("id", "") + data = { + "image_name": f"{MTA_IMAGE}:{MTA_IMNAGE_VERSION}", + "container_name": f"MTA-{id}", + "command": command, + "timeout": 3600, + "result_file": result_file, + "result_file_name": OUTPUT_PIC_NAME, + "id": id, + } + volumes = {CONTAINER_OUTER_PATH: BATH_ROOT} + + environment = {ENV_PARAM_KEY: ENV_PARAM_VALUE} + + def on_task_completion(task_result): + print("---------------------- 进入 回调方法 ----------------------") + """任务完成回调""" + # 手动创建应用上下文 + with app.app_context(): + # 从task_result中获取id,如果不存在则使用闭包变量id + + item = ( + db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + ) + if task_result.get("status") == "completed": + try: + if item: + item.is_run_mta = True + item.mta_is_running = False + item.mta_task_status = TaskStatus.COMPLETED.value + item.mta_task_id = None + item.run_mta_result = OUTPUT_PIC_NAME + item.updated_at = func.now() + db.session.commit() + except Exception as e: + app.logger.error(f"回调函数数据库操作失败: {e}") + db.session.rollback() + elif task_result.get("status") == "cancelled": + # 失败的情况 + item.is_run_mta = True + item.mta_is_running = True + item.mta_task_status = TaskStatus.CANCELLED.value + item.mta_task_id = None + item.run_mta_result = None + item.updated_at = func.now() + db.session.commit() + else: + # 失败的情况 + item.is_run_mta = True + item.mta_is_running = True + item.mta_task_status = TaskStatus.FAILED.value + item.mta_task_id = None + item.run_mta_result = None + item.updated_at = func.now() + db.session.commit() + + print(f"任务 {id} 数据库更新成功") + + try: + """查看队列状态""" + status = task_queue.get_queue_status() + + # 提交任务到队列 + result = task_queue.submit_task( + LongRunningTasks.api_create_container, + container_config=data, + volumes=volumes, + environment=environment, + completion_callback=on_task_completion, + ) + run_mta_command_result["data"] = result + status = result["status"] + task_id = result["BATH_ROOT"] + item = db.session.query(SessionRecord).filter(SessionRecord.id == id).first() + if not item: + return jsonify({"error": f"记录不存在,id={id}"}), 404 + + # 更新字段 + item.mta_is_running = True + + item.mta_task_id = task_id + item.updated_at = func.now() # 可选,如果模型没有自动更新 + print(f"任务地位:{id},task 返回结果为 :{status}") + # + if status == "PENDING": + item.mta_task_status = TaskStatus.PENDING.value + run_mta_command_result["message"] = ( + f"当前正在执行任务数:{running_count},新建任务已进入队列" + ) + elif status == "RUNNING": + item.mta_task_status = TaskStatus.RUNNING.value + run_mta_command_result["message"] = "MTA 任务已经启动" + db.session.commit() + return run_mta_command_result + + except Exception as e: + app.logger.exception("查询接口异常") + return {"success": False, "data": e, "message": "任务提交异常"} + + +if __name__ == "__main__": + # 本地开发:http://127.0.0.1:5000 + + with app.app_context(): + db.create_all() + + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/fst_data_pipeline/apps/mta_manage_system/config.py b/fst_data_pipeline/apps/mta_manage_system/config.py new file mode 100644 index 0000000..226ae1a --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/config.py @@ -0,0 +1,105 @@ +import os +import logging +from dotenv import load_dotenv +from pathlib import Path + + +def load_environment(): + """智能加载环境配置""" + env = os.getenv("FLASK_ENV", "development") + + # 加载顺序:基础配置 -> 环境特定配置 -> 本地覆盖 + env_files = [ + ".env", # 基础配置 + f".env.{env}", # 环境特定配置 + ".env.local", # 本地覆盖(可选) + ] + + for env_file in env_files: + if Path(env_file).exists(): + load_dotenv(env_file) + logging.info(f"加载环境文件: {env_file}") + + return env + + +class Config: + """动态配置类""" + + def __init__(self): + self.env = load_environment() + self._load_config() + + def _load_config(self): + """加载所有配置""" + # 路径配置 + self.ABSOLUTE_HTML_PATH = os.getenv("ABSOLUTE_HTML_PATH") + self.BATH_ROOT = os.getenv("BATH_ROOT") + self.IMAGE_FOLDER = os.getenv("IMAGE_FOLDER") + self.SCRIPT_PATH = os.getenv("SCRIPT_PATH") + self.HTML_DIR = os.getenv("HTML_DIR") + self.ABSOLUTE_PATH = os.getenv("ABSOLUTE_PATH") + + # 数据库配置 + self.SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") + self.SQLALCHEMY_TRACK_MODIFICATIONS = False + + # 应用配置 + self.MAX_PER_PAGE = int(os.getenv("MAX_PER_PAGE", 100)) + self.DEFAULT_PER_PAGE = int(os.getenv("DEFAULT_PER_PAGE", 10)) + self.LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + self.SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key") + + # Flask 配置 + self.DEBUG = os.getenv("DEBUG", "False").lower() == "true" + self.HOST = os.getenv("HOST", "0.0.0.0") + self.PORT = int(os.getenv("PORT", 5000)) + + # 文件路径 + self.APP_ROOT = os.path.dirname(os.path.abspath(__file__)) + self.PAGES_DIR = os.path.join(self.APP_ROOT, "static/pages") + self.ALLOWED_EXTS = {".html", ".htm"} + + # 调度器配置 + self.ENABLE_SCHEDULER = os.getenv("ENABLE_SCHEDULER", "true").lower() == "true" + self.SCHEDULER_INTERVAL = int(os.getenv("SCHEDULER_INTERVAL", 5)) + + # MTA task 数量 + self.MAX_ALLOW_RUNNING_MTA_TASK = int( + os.getenv("MAX_ALLOW_RUNNING_MTA_TASK", 3) + ) + + self.OUTPUT_PIC_PATH = os.getenv( + "OUTPUT_PIC_PATH", "/mta_middle_proc/global_results/" + ) + self.OUTPUT_PIC_NAME = os.getenv( + "OUTPUT_PIC_NAME", "merged_ground_voxel_max_intensity_bev_zfilter.png" + ) + self.OUTPUT_RESULT_PATH = os.getenv( + "OUTPUT_RESULT_PATH", "/mta_middle_proc/global_results/slam_lidar_ground/" + ) + self.CONTAINER_INNER_PATH = os.getenv("CONTAINER_INNER_PATH", "/mnt/mta") + self.CONTAINER_OUTER_PATH = os.getenv("CONTAINER_OUTER_PATH", "/data/mnt/") + self.ENV_PARAM_KEY = os.getenv("ENV_PARAM_KEY", "ROS_MASTER_URL") + self.ENV_PARAM_VALUE = os.getenv("ENV_PARAM_VALUE", "http://localhost:11311") + self.MTA_SCRIPT = os.getenv("MTA_SCRIPT", "/app/mta_run_param.sh") + + self.MTA_IMAGE = os.getenv("MTA_IMAGE", "/mta_middle_proc/global_results/") + self.MTA_IMNAGE_VERSION = os.getenv("MTA_IMNAGE_VERSION", "1.51") + self.RESULT_PATH = os.getenv("RESULT_PATH", "/data/mnt/") + + def __repr__(self): + return f"" + + +# 初始化配置 +config = Config() + +# 配置日志 +logging.basicConfig( + level=getattr(logging, config.LOG_LEVEL), + format="[%(levelname)s] %(asctime)s - %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + +logger = logging.getLogger(__name__) diff --git a/fst_data_pipeline/apps/mta_manage_system/db/init.sql b/fst_data_pipeline/apps/mta_manage_system/db/init.sql new file mode 100644 index 0000000..b28d5c6 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/db/init.sql @@ -0,0 +1,39 @@ + +CREATE DATABASE IF NOT EXISTS mta; +use mta ; +drop TABLE IF EXISTS `session_selection` ; +CREATE TABLE IF NOT EXISTS `session_selection` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `html_name` VARCHAR(255) NOT NULL COMMENT ' html name ', + `html_folder` VARCHAR(255) NOT NULL COMMENT ' html 文件存储文件夹 ', + `bag_folder` VARCHAR(255) NOT NULL COMMENT ' html 的数据包存储文件夹 ', + `central_session` VARCHAR(255) NOT NULL COMMENT '单选:central session', + `query_sessions` VARCHAR(1024) NOT NULL COMMENT '多选:query session 列表', + `selected_image_url` json DEFAULT NULL COMMENT '页面展示的已选图片URL', + `is_filter_bag` boolean DEFAULT NULL COMMENT '是否筛选数据包', + `is_run_mta` boolean DEFAULT NULL COMMENT '是否跑了mta 任务', + `run_mta_result` VARCHAR(255) DEFAULT NULL COMMENT 'mta 任务结果', + `remark` VARCHAR(1024) COMMENT '备注信息', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + + +-- 示例:按 central_session 查询较多时 +CREATE INDEX idx_central_session ON `session_selection` (`central_session`); +-- 示例:按创建时间范围查询 +CREATE INDEX idx_created_at ON `session_selection` (`created_at`); + +-- 1106 +-- 添加新字段到 session_selection 表 + +ALTER TABLE `session_selection` +ADD COLUMN `mta_is_running` BOOLEAN DEFAULT FALSE COMMENT 'mta task 是否在运行'; + +ALTER TABLE `session_selection` +ADD COLUMN `mta_task_status` VARCHAR(255) DEFAULT 'NOT_STARTED' COMMENT 'mta task 状态:NOT_STARTED, PENDING, RUNNING, FAILED, COMPLETED ,CANCELLED'; + +ALTER TABLE `session_selection` +ADD COLUMN `mta_task_id` VARCHAR(255) DEFAULT NULL COMMENT 'mta task ID'; \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/docker/Dockerfile b/fst_data_pipeline/apps/mta_manage_system/docker/Dockerfile new file mode 100644 index 0000000..0c475c1 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/docker/Dockerfile @@ -0,0 +1,39 @@ +# 使用官方 Python 基础镜像 +FROM python:3.12 + +# 设置工作目录 +WORKDIR /app + + + + +# 安装 uv +RUN pip install uv + +# 复制 pyproject.toml 和 poetry.lock 文件(如果有的话) +COPY pyproject.toml . + +# 使用 uv 安装依赖,使用 --system 参数 可以应用参数 --index-url https://mirrors.aliyun.com/pypi/simple/ 加速 +RUN uv pip install --system . + + +# 复制项目文件 +COPY models/ ./models/ +COPY services/ ./services/ +COPY static/ ./static/ +COPY templates/ ./templates/ +COPY utils/ ./utils/ +COPY app.py . + +COPY extensions.py . +COPY config.py . +COPY .env . +COPY .devenv . +COPY __init__.py . +COPY README.md . + +# 暴露端口(假设你的Flask应用运行在5000端口) +EXPOSE 5000 + +# 启动命令 +CMD ["python", "app.py"] \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/docker/Readme.md b/fst_data_pipeline/apps/mta_manage_system/docker/Readme.md new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/mta_manage_system/extensions.py b/fst_data_pipeline/apps/mta_manage_system/extensions.py new file mode 100644 index 0000000..9830deb --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/extensions.py @@ -0,0 +1,4 @@ +from flask_sqlalchemy import SQLAlchemy + +# 创建全局 db 实例 +db = SQLAlchemy() diff --git a/fst_data_pipeline/apps/mta_manage_system/models/__init__.py b/fst_data_pipeline/apps/mta_manage_system/models/__init__.py new file mode 100644 index 0000000..9b572ce --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/models/__init__.py @@ -0,0 +1,6 @@ +# models/__init__.py +# 导出模型类,方便从外部导入 +from .sessionrecord import SessionRecord + +# 可以在这里导入所有模型 +__all__ = ["SessionRecord"] diff --git a/fst_data_pipeline/apps/mta_manage_system/models/sessionrecord.py b/fst_data_pipeline/apps/mta_manage_system/models/sessionrecord.py new file mode 100644 index 0000000..d73126a --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/models/sessionrecord.py @@ -0,0 +1,95 @@ +from typing import List, Set, Dict +from datetime import datetime +import json +from extensions import db + + +# ===== 数据模型 ===== +class SessionRecord(db.Model): + __tablename__ = "session_selection" + + id = db.Column( + db.BigInteger().with_variant(db.Integer, "sqlite"), + primary_key=True, + autoincrement=True, + comment="自增主键", + ) + html_name = db.Column(db.String(255), nullable=False, comment="html name") + html_folder = db.Column(db.String(255), nullable=False, comment="html folder") + bag_folder = db.Column(db.String(255), nullable=False, comment="bag folder") + central_session = db.Column( + db.String(255), nullable=False, comment="单选:central session" + ) + query_sessions = db.Column( + db.String(1024), nullable=False, comment="多选:query session 列表" + ) + selected_image_url = db.Column( + db.Text().with_variant(db.String(1024), "sqlite"), # 兼容 MySQL JSON 与 SQLite + nullable=True, + comment="页面展示的已选图片URL", + ) + is_filter_bag = db.Column( + db.Boolean, nullable=True, default=None, comment="是否筛选数据包" + ) + is_run_mta = db.Column( + db.Boolean, nullable=True, default=None, comment="是否跑了mta任务" + ) + mta_is_running = db.Column( + db.Boolean, nullable=False, default=False, comment="mta task 是否在运行" + ) + mta_task_status = db.Column( + db.String(255), + nullable=False, + default=False, + comment="mta task 状态:NOT_STARTED ,RUNNING ,COMPLETED", + ) + mta_task_id = db.Column( + db.String(255), nullable=False, comment="task id:mta_task_id" + ) + run_mta_result = db.Column(db.String(255), nullable=True, comment="mta任务结果") + remark = db.Column(db.String(1024), nullable=False, comment="备注信息") + created_at = db.Column( + db.DateTime, nullable=False, default=datetime.utcnow, comment="创建时间" + ) + updated_at = db.Column( # 修正字段名为 updated_at + db.DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + def to_dict(self): + return { + "id": self.id, + "html_name": self.html_name, + "html_folder": self.html_folder, + "bag_folder": self.bag_folder, + "central_session": self.central_session, + "query_sessions": self.query_sessions, + "selected_image_url": self._parse_json_url(self.selected_image_url), + "is_filter_bag": self.is_filter_bag, + "is_run_mta": self.is_run_mta, + "mta_task_status": self.mta_task_status, + "mta_is_running": self.mta_is_running, + "mta_task_id": self.mta_task_id, + "run_mta_result": self.run_mta_result, + "remark": self.remark, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @staticmethod + def _parse_json_url(value): + """ + 将数据库中的 JSON 字段或字符串安全地反序列化为 Python 对象。 + MySQL 返回 list/dict;SQLite 可能返回字符串或 None。 + """ + if value is None: + return None + if isinstance(value, (list, dict)): + return value + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError): + return value # 无法解析时原样返回(如旧数据为字符串) diff --git a/fst_data_pipeline/apps/mta_manage_system/models/taskstatus.py b/fst_data_pipeline/apps/mta_manage_system/models/taskstatus.py new file mode 100644 index 0000000..8afc834 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/models/taskstatus.py @@ -0,0 +1,65 @@ +from enum import Enum +from typing import List, Set, Dict, Any + + +class TaskStatus(Enum): + """任务状态枚举类""" + + NOT_STARTED = "NOT_STARTED" # 未开始 + PENDING = "PENDING" # 队列等待中 + RUNNING = "RUNNING" # 执行中 + COMPLETED = "COMPLETED" # 已完成 + FAILED = "FAILED" # 失败 + CANCELLED = "CANCELLED" # 已取消 + + @classmethod + def get_active_statuses(cls) -> Set[str]: + """获取活跃状态集合(未完成的状态)""" + return {cls.NOT_STARTED.value, cls.PENDING.value, cls.RUNNING.value} + + @classmethod + def get_final_statuses(cls) -> Set[str]: + """获取最终状态集合(不会改变的状态)""" + return {cls.COMPLETED.value, cls.FAILED.value, cls.CANCELLED.value} + + @classmethod + def get_editable_statuses(cls) -> Set[str]: + """获取可编辑状态集合(可以手动改变的状态)""" + return {cls.NOT_STARTED.value, cls.PENDING.value} + + @classmethod + def is_active(cls, status: str) -> bool: + """检查是否为活跃状态(未完成)""" + return status in cls.get_active_statuses() + + @classmethod + def is_final(cls, status: str) -> bool: + """检查是否为最终状态(不会改变)""" + return status in cls.get_final_statuses() + + @classmethod + def is_editable(cls, status: str) -> bool: + """检查状态是否可手动编辑""" + return status in cls.get_editable_statuses() + + @classmethod + def validate_status(cls, status: str) -> bool: + """验证状态值是否有效""" + return any(status == member.value for member in cls) + + @classmethod + def get_all_statuses(cls) -> List[str]: + """获取所有状态值列表""" + return [member.value for member in cls] + + @classmethod + def get_status_choices(cls) -> List[Dict[str, Any]]: + """获取状态选项(用于前端下拉选择)""" + return [ + {"value": cls.NOT_STARTED.value, "label": "未开始", "color": "default"}, + {"value": cls.PENDING.value, "label": "等待中", "color": "orange"}, + {"value": cls.RUNNING.value, "label": "执行中", "color": "blue"}, + {"value": cls.COMPLETED.value, "label": "已完成", "color": "green"}, + {"value": cls.FAILED.value, "label": "已失败", "color": "red"}, + {"value": cls.CANCELLED.value, "label": "已取消", "color": "gray"}, + ] diff --git a/fst_data_pipeline/apps/mta_manage_system/pyproject.toml b/fst_data_pipeline/apps/mta_manage_system/pyproject.toml new file mode 100644 index 0000000..aa4ad84 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "mta_management_system" +version = "0.1.0" +description = "cloud infra pipeline for mb fst data production" +authors = [{ name = "MBRDCA", email = "tbd@tbd.com" }] + +requires-python = ">=3.12" +dependencies = [ + "flask>=3.1.1", + "jinja2==3.1.6", + "werkzeug==3.1.3", + "click==8.2.1", + "xmltodict==0.14.2", + + "python-dotenv==1.1.1", + "itsdangerous==2.2.0", + "flask_sqlalchemy==3.0.5", + "pymysql==1.1.0", + "paramiko==3.1.0", + "docker==6.1.3", + "APScheduler==3.10", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mta_management_system"] diff --git a/fst_data_pipeline/apps/mta_manage_system/services/long_running_tasks.py b/fst_data_pipeline/apps/mta_manage_system/services/long_running_tasks.py new file mode 100644 index 0000000..f117586 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/services/long_running_tasks.py @@ -0,0 +1,228 @@ +import time +import logging +from datetime import datetime + +from typing import Dict, Any +from utils.docker_tool import docker_tool +from extensions import db # 统一导入 db 实例 +from models.sessionrecord import SessionRecord + + +logger = logging.getLogger(__name__) + + +class LongRunningTasks: + """长时间任务处理类""" + + @staticmethod + def api_create_container( + task, + container_config: Dict[str, Any], + volumes: Dict[str, Any], + environment: Dict[str, Any], + ): + """ + 调用API创建容器并等待完成 + + Args: + container_config: 容器配置,包含: + - image_name: 镜像名称 + - container_name: 容器名称(可选) + - command: 启动命令(可选) + - timeout: 超时时间(秒,默认3600) + + Returns: + Dict: 执行结果 + """ + + try: + # 提取配置参数 + + image_name = container_config.get("image_name", "ubuntu:latest") + container_name = container_config.get("container_name") + command = container_config.get("command") + timeout = container_config.get("timeout", 3600) + result_file = container_config.get("result_file") + id = container_config.get("id") + result_file_name = container_config.get("result_file_name") + if not image_name: + raise ValueError("必须提供image_name参数") + logger.info( + f"开始创建容器: {container_name or '未命名'}, 镜像: {image_name}" + ) + + # 调用工具类创建容器 + # 检查Docker环境 + if not docker_tool.is_docker_available(): + return { + "success": False, + "error": "宿主机Docker环境不可用", + "message": "请检查Docker是否正确挂载", + } + # 在创建容器之前先创建同名容器是否在运行或者已经存在,如果已经在运行则 停止 ,并删除, 如果没有则进行创建 + containers = docker_tool.get_containers() + mta_container = None + for container in containers: + names = container.get("Names", "") + # 精确匹配MTA-1(考虑Docker可能添加/前缀的情况) + if names == container_name: + mta_container = container + break + + if mta_container: + container_id = mta_container.get("ID", mta_container.get("Id", "")) + print(f"找到MTA容器,ID: {container_id}") + # 停止容器 + stop_result = docker_tool.stop_container(container_id) + if not stop_result["success"]: + print(f"停止容器失败: {stop_result.get('error', '未知错误')}") + return + + print("容器已停止") + + # 删除容器 + remove_result = docker_tool.remove_container(container_id) + if remove_result["success"]: + print(f"{container_name}容器已成功删除") + else: + print(f"删除容器失败: {remove_result.get('error', '未知错误')}") + print( + f"创建容器参数command: {command} ,volumes 挂载: {volumes},environment 参数:{environment} " + ) + # 使用工具类创建容器 + result = docker_tool.create_container( + image_name=image_name, + container_name=container_name, + command=command, + volumes=volumes, + environment=environment, + ) + if result["success"]: + print(" 容器创建成功") + container_id = result["container_id"] + + # 根据调用的工具类创建容器 ,根据工具类创建容器的返回值 判断容器是否创建成功 + completion_file = result_file + logger.info(f"等待完成文件: {completion_file}") + + # 轮询MTA 的结果文件否存在 + start_time = time.time() + check_count = 0 + + while time.time() - start_time < timeout: + check_count += 1 + # 获取 容器运行状态 如果容器停止运行且结果文件不存在则抛出异常 + container_result = docker_tool.get_container_status_by_id( + container_id + ) + state = container_result.get("state", "").lower() + if ( + state != "running" + and not LongRunningTasks._check_completion_file(completion_file) + ): + # 确保容器被清理 + try: + docker_tool.stop_container(container_name) + docker_tool.remove_container(container_name) + except Exception as cleanup_e: + logger.error(f"清理容器失败: {cleanup_e}") + raise Exception( + f"容器状态异常: {state}, 结果文件未生成: {completion_file}" + ) + + # 检查完成文件是否存在 同时判断 容器是否运行正常, 如果 + if LongRunningTasks._check_completion_file(completion_file): + logger.info(f"MTA 任务执行 完成: {container_name}") + # 判断 任务执行完成后 调用工具类 停止容器 然后删除容器 + docker_tool.stop_container(container_name) + docker_tool.remove_container(container_name) + + return { + "status": "completed", + "container_name": container_name, + "image_name": image_name, + "id": id, # 添加id到返回结果中,供回调函数使用 + "command": command, + "completion_file": completion_file, + "completed_at": datetime.now().isoformat(), + "total_wait_seconds": int(time.time() - start_time), + "check_count": check_count, + } + + # 等待5秒再检查 + time.sleep(5) + print( + f"第{check_count}次检查路径:{completion_file},完成文件尚未出现..." + ) + logger.info(f"第{check_count}次检查,完成文件尚未出现...") + else: + print(" 容器创建失败") + + logger.info("调用API创建容器...") + + except Exception as e: + logger.error(f"创建容器失败: {e}") + raise + + @staticmethod + def _check_completion_file(file_path: str) -> bool: + import os + + if not os.path.exists(file_path): + return False + + for filename in os.listdir(file_path): + if filename.endswith(".pcd"): + return True + return False + + @staticmethod + def process_with_completion_check(process_config: Dict[str, Any]): + """ + 通用处理任务,使用文件检查判断完成 + + Args: + process_config: 处理配置,包含: + - process_type: 处理类型 + - completion_file: 完成标志文件路径 + - timeout: 超时时间(秒) + + Returns: + Dict: 执行结果 + """ + try: + process_type = process_config.get("process_type", "unknown") + completion_file = process_config.get("completion_file") + timeout = process_config.get("timeout", 7200) + + if not completion_file: + raise ValueError("必须提供completion_file参数") + + logger.info(f"开始处理任务: {process_type}, 完成文件: {completion_file}") + + # 轮询检查完成文件 + start_time = time.time() + check_count = 0 + + while time.time() - start_time < timeout: + check_count += 1 + + if LongRunningTasks._check_completion_file(completion_file): + logger.info(f"处理任务完成: {process_type}") + return { + "status": "completed", + "process_type": process_type, + "completion_file": completion_file, + "completed_at": datetime.now().isoformat(), + "total_wait_seconds": int(time.time() - start_time), + "check_count": check_count, + } + + time.sleep(60) # 每分钟检查一次 + logger.info(f"第{check_count}次检查,任务处理中...") + + raise Exception(f"处理任务超时({timeout}秒)") + + except Exception as e: + logger.error(f"处理任务失败: {e}") + raise diff --git a/fst_data_pipeline/apps/mta_manage_system/static/css/bootstrap-icons.css b/fst_data_pipeline/apps/mta_manage_system/static/css/bootstrap-icons.css new file mode 100644 index 0000000..f3f6a67 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/css/bootstrap-icons.css @@ -0,0 +1,2079 @@ +/*! + * Bootstrap Icons v1.10.5 (https://icons.getbootstrap.com/) + * Copyright 2019-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */ + + @font-face { + font-display: block; + font-family: "bootstrap-icons"; + src: url("./fonts/bootstrap-icons.woff2?1bb88866b4085542c8ed5fb61b9393dd") format("woff2"), + url("./fonts/bootstrap-icons.woff?1bb88866b4085542c8ed5fb61b9393dd") format("woff"); + } + + .bi::before, + [class^="bi-"]::before, + [class*=" bi-"]::before { + display: inline-block; + font-family: bootstrap-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .bi-123::before { content: "\f67f"; } + .bi-alarm-fill::before { content: "\f101"; } + .bi-alarm::before { content: "\f102"; } + .bi-align-bottom::before { content: "\f103"; } + .bi-align-center::before { content: "\f104"; } + .bi-align-end::before { content: "\f105"; } + .bi-align-middle::before { content: "\f106"; } + .bi-align-start::before { content: "\f107"; } + .bi-align-top::before { content: "\f108"; } + .bi-alt::before { content: "\f109"; } + .bi-app-indicator::before { content: "\f10a"; } + .bi-app::before { content: "\f10b"; } + .bi-archive-fill::before { content: "\f10c"; } + .bi-archive::before { content: "\f10d"; } + .bi-arrow-90deg-down::before { content: "\f10e"; } + .bi-arrow-90deg-left::before { content: "\f10f"; } + .bi-arrow-90deg-right::before { content: "\f110"; } + .bi-arrow-90deg-up::before { content: "\f111"; } + .bi-arrow-bar-down::before { content: "\f112"; } + .bi-arrow-bar-left::before { content: "\f113"; } + .bi-arrow-bar-right::before { content: "\f114"; } + .bi-arrow-bar-up::before { content: "\f115"; } + .bi-arrow-clockwise::before { content: "\f116"; } + .bi-arrow-counterclockwise::before { content: "\f117"; } + .bi-arrow-down-circle-fill::before { content: "\f118"; } + .bi-arrow-down-circle::before { content: "\f119"; } + .bi-arrow-down-left-circle-fill::before { content: "\f11a"; } + .bi-arrow-down-left-circle::before { content: "\f11b"; } + .bi-arrow-down-left-square-fill::before { content: "\f11c"; } + .bi-arrow-down-left-square::before { content: "\f11d"; } + .bi-arrow-down-left::before { content: "\f11e"; } + .bi-arrow-down-right-circle-fill::before { content: "\f11f"; } + .bi-arrow-down-right-circle::before { content: "\f120"; } + .bi-arrow-down-right-square-fill::before { content: "\f121"; } + .bi-arrow-down-right-square::before { content: "\f122"; } + .bi-arrow-down-right::before { content: "\f123"; } + .bi-arrow-down-short::before { content: "\f124"; } + .bi-arrow-down-square-fill::before { content: "\f125"; } + .bi-arrow-down-square::before { content: "\f126"; } + .bi-arrow-down-up::before { content: "\f127"; } + .bi-arrow-down::before { content: "\f128"; } + .bi-arrow-left-circle-fill::before { content: "\f129"; } + .bi-arrow-left-circle::before { content: "\f12a"; } + .bi-arrow-left-right::before { content: "\f12b"; } + .bi-arrow-left-short::before { content: "\f12c"; } + .bi-arrow-left-square-fill::before { content: "\f12d"; } + .bi-arrow-left-square::before { content: "\f12e"; } + .bi-arrow-left::before { content: "\f12f"; } + .bi-arrow-repeat::before { content: "\f130"; } + .bi-arrow-return-left::before { content: "\f131"; } + .bi-arrow-return-right::before { content: "\f132"; } + .bi-arrow-right-circle-fill::before { content: "\f133"; } + .bi-arrow-right-circle::before { content: "\f134"; } + .bi-arrow-right-short::before { content: "\f135"; } + .bi-arrow-right-square-fill::before { content: "\f136"; } + .bi-arrow-right-square::before { content: "\f137"; } + .bi-arrow-right::before { content: "\f138"; } + .bi-arrow-up-circle-fill::before { content: "\f139"; } + .bi-arrow-up-circle::before { content: "\f13a"; } + .bi-arrow-up-left-circle-fill::before { content: "\f13b"; } + .bi-arrow-up-left-circle::before { content: "\f13c"; } + .bi-arrow-up-left-square-fill::before { content: "\f13d"; } + .bi-arrow-up-left-square::before { content: "\f13e"; } + .bi-arrow-up-left::before { content: "\f13f"; } + .bi-arrow-up-right-circle-fill::before { content: "\f140"; } + .bi-arrow-up-right-circle::before { content: "\f141"; } + .bi-arrow-up-right-square-fill::before { content: "\f142"; } + .bi-arrow-up-right-square::before { content: "\f143"; } + .bi-arrow-up-right::before { content: "\f144"; } + .bi-arrow-up-short::before { content: "\f145"; } + .bi-arrow-up-square-fill::before { content: "\f146"; } + .bi-arrow-up-square::before { content: "\f147"; } + .bi-arrow-up::before { content: "\f148"; } + .bi-arrows-angle-contract::before { content: "\f149"; } + .bi-arrows-angle-expand::before { content: "\f14a"; } + .bi-arrows-collapse::before { content: "\f14b"; } + .bi-arrows-expand::before { content: "\f14c"; } + .bi-arrows-fullscreen::before { content: "\f14d"; } + .bi-arrows-move::before { content: "\f14e"; } + .bi-aspect-ratio-fill::before { content: "\f14f"; } + .bi-aspect-ratio::before { content: "\f150"; } + .bi-asterisk::before { content: "\f151"; } + .bi-at::before { content: "\f152"; } + .bi-award-fill::before { content: "\f153"; } + .bi-award::before { content: "\f154"; } + .bi-back::before { content: "\f155"; } + .bi-backspace-fill::before { content: "\f156"; } + .bi-backspace-reverse-fill::before { content: "\f157"; } + .bi-backspace-reverse::before { content: "\f158"; } + .bi-backspace::before { content: "\f159"; } + .bi-badge-3d-fill::before { content: "\f15a"; } + .bi-badge-3d::before { content: "\f15b"; } + .bi-badge-4k-fill::before { content: "\f15c"; } + .bi-badge-4k::before { content: "\f15d"; } + .bi-badge-8k-fill::before { content: "\f15e"; } + .bi-badge-8k::before { content: "\f15f"; } + .bi-badge-ad-fill::before { content: "\f160"; } + .bi-badge-ad::before { content: "\f161"; } + .bi-badge-ar-fill::before { content: "\f162"; } + .bi-badge-ar::before { content: "\f163"; } + .bi-badge-cc-fill::before { content: "\f164"; } + .bi-badge-cc::before { content: "\f165"; } + .bi-badge-hd-fill::before { content: "\f166"; } + .bi-badge-hd::before { content: "\f167"; } + .bi-badge-tm-fill::before { content: "\f168"; } + .bi-badge-tm::before { content: "\f169"; } + .bi-badge-vo-fill::before { content: "\f16a"; } + .bi-badge-vo::before { content: "\f16b"; } + .bi-badge-vr-fill::before { content: "\f16c"; } + .bi-badge-vr::before { content: "\f16d"; } + .bi-badge-wc-fill::before { content: "\f16e"; } + .bi-badge-wc::before { content: "\f16f"; } + .bi-bag-check-fill::before { content: "\f170"; } + .bi-bag-check::before { content: "\f171"; } + .bi-bag-dash-fill::before { content: "\f172"; } + .bi-bag-dash::before { content: "\f173"; } + .bi-bag-fill::before { content: "\f174"; } + .bi-bag-plus-fill::before { content: "\f175"; } + .bi-bag-plus::before { content: "\f176"; } + .bi-bag-x-fill::before { content: "\f177"; } + .bi-bag-x::before { content: "\f178"; } + .bi-bag::before { content: "\f179"; } + .bi-bar-chart-fill::before { content: "\f17a"; } + .bi-bar-chart-line-fill::before { content: "\f17b"; } + .bi-bar-chart-line::before { content: "\f17c"; } + .bi-bar-chart-steps::before { content: "\f17d"; } + .bi-bar-chart::before { content: "\f17e"; } + .bi-basket-fill::before { content: "\f17f"; } + .bi-basket::before { content: "\f180"; } + .bi-basket2-fill::before { content: "\f181"; } + .bi-basket2::before { content: "\f182"; } + .bi-basket3-fill::before { content: "\f183"; } + .bi-basket3::before { content: "\f184"; } + .bi-battery-charging::before { content: "\f185"; } + .bi-battery-full::before { content: "\f186"; } + .bi-battery-half::before { content: "\f187"; } + .bi-battery::before { content: "\f188"; } + .bi-bell-fill::before { content: "\f189"; } + .bi-bell::before { content: "\f18a"; } + .bi-bezier::before { content: "\f18b"; } + .bi-bezier2::before { content: "\f18c"; } + .bi-bicycle::before { content: "\f18d"; } + .bi-binoculars-fill::before { content: "\f18e"; } + .bi-binoculars::before { content: "\f18f"; } + .bi-blockquote-left::before { content: "\f190"; } + .bi-blockquote-right::before { content: "\f191"; } + .bi-book-fill::before { content: "\f192"; } + .bi-book-half::before { content: "\f193"; } + .bi-book::before { content: "\f194"; } + .bi-bookmark-check-fill::before { content: "\f195"; } + .bi-bookmark-check::before { content: "\f196"; } + .bi-bookmark-dash-fill::before { content: "\f197"; } + .bi-bookmark-dash::before { content: "\f198"; } + .bi-bookmark-fill::before { content: "\f199"; } + .bi-bookmark-heart-fill::before { content: "\f19a"; } + .bi-bookmark-heart::before { content: "\f19b"; } + .bi-bookmark-plus-fill::before { content: "\f19c"; } + .bi-bookmark-plus::before { content: "\f19d"; } + .bi-bookmark-star-fill::before { content: "\f19e"; } + .bi-bookmark-star::before { content: "\f19f"; } + .bi-bookmark-x-fill::before { content: "\f1a0"; } + .bi-bookmark-x::before { content: "\f1a1"; } + .bi-bookmark::before { content: "\f1a2"; } + .bi-bookmarks-fill::before { content: "\f1a3"; } + .bi-bookmarks::before { content: "\f1a4"; } + .bi-bookshelf::before { content: "\f1a5"; } + .bi-bootstrap-fill::before { content: "\f1a6"; } + .bi-bootstrap-reboot::before { content: "\f1a7"; } + .bi-bootstrap::before { content: "\f1a8"; } + .bi-border-all::before { content: "\f1a9"; } + .bi-border-bottom::before { content: "\f1aa"; } + .bi-border-center::before { content: "\f1ab"; } + .bi-border-inner::before { content: "\f1ac"; } + .bi-border-left::before { content: "\f1ad"; } + .bi-border-middle::before { content: "\f1ae"; } + .bi-border-outer::before { content: "\f1af"; } + .bi-border-right::before { content: "\f1b0"; } + .bi-border-style::before { content: "\f1b1"; } + .bi-border-top::before { content: "\f1b2"; } + .bi-border-width::before { content: "\f1b3"; } + .bi-border::before { content: "\f1b4"; } + .bi-bounding-box-circles::before { content: "\f1b5"; } + .bi-bounding-box::before { content: "\f1b6"; } + .bi-box-arrow-down-left::before { content: "\f1b7"; } + .bi-box-arrow-down-right::before { content: "\f1b8"; } + .bi-box-arrow-down::before { content: "\f1b9"; } + .bi-box-arrow-in-down-left::before { content: "\f1ba"; } + .bi-box-arrow-in-down-right::before { content: "\f1bb"; } + .bi-box-arrow-in-down::before { content: "\f1bc"; } + .bi-box-arrow-in-left::before { content: "\f1bd"; } + .bi-box-arrow-in-right::before { content: "\f1be"; } + .bi-box-arrow-in-up-left::before { content: "\f1bf"; } + .bi-box-arrow-in-up-right::before { content: "\f1c0"; } + .bi-box-arrow-in-up::before { content: "\f1c1"; } + .bi-box-arrow-left::before { content: "\f1c2"; } + .bi-box-arrow-right::before { content: "\f1c3"; } + .bi-box-arrow-up-left::before { content: "\f1c4"; } + .bi-box-arrow-up-right::before { content: "\f1c5"; } + .bi-box-arrow-up::before { content: "\f1c6"; } + .bi-box-seam::before { content: "\f1c7"; } + .bi-box::before { content: "\f1c8"; } + .bi-braces::before { content: "\f1c9"; } + .bi-bricks::before { content: "\f1ca"; } + .bi-briefcase-fill::before { content: "\f1cb"; } + .bi-briefcase::before { content: "\f1cc"; } + .bi-brightness-alt-high-fill::before { content: "\f1cd"; } + .bi-brightness-alt-high::before { content: "\f1ce"; } + .bi-brightness-alt-low-fill::before { content: "\f1cf"; } + .bi-brightness-alt-low::before { content: "\f1d0"; } + .bi-brightness-high-fill::before { content: "\f1d1"; } + .bi-brightness-high::before { content: "\f1d2"; } + .bi-brightness-low-fill::before { content: "\f1d3"; } + .bi-brightness-low::before { content: "\f1d4"; } + .bi-broadcast-pin::before { content: "\f1d5"; } + .bi-broadcast::before { content: "\f1d6"; } + .bi-brush-fill::before { content: "\f1d7"; } + .bi-brush::before { content: "\f1d8"; } + .bi-bucket-fill::before { content: "\f1d9"; } + .bi-bucket::before { content: "\f1da"; } + .bi-bug-fill::before { content: "\f1db"; } + .bi-bug::before { content: "\f1dc"; } + .bi-building::before { content: "\f1dd"; } + .bi-bullseye::before { content: "\f1de"; } + .bi-calculator-fill::before { content: "\f1df"; } + .bi-calculator::before { content: "\f1e0"; } + .bi-calendar-check-fill::before { content: "\f1e1"; } + .bi-calendar-check::before { content: "\f1e2"; } + .bi-calendar-date-fill::before { content: "\f1e3"; } + .bi-calendar-date::before { content: "\f1e4"; } + .bi-calendar-day-fill::before { content: "\f1e5"; } + .bi-calendar-day::before { content: "\f1e6"; } + .bi-calendar-event-fill::before { content: "\f1e7"; } + .bi-calendar-event::before { content: "\f1e8"; } + .bi-calendar-fill::before { content: "\f1e9"; } + .bi-calendar-minus-fill::before { content: "\f1ea"; } + .bi-calendar-minus::before { content: "\f1eb"; } + .bi-calendar-month-fill::before { content: "\f1ec"; } + .bi-calendar-month::before { content: "\f1ed"; } + .bi-calendar-plus-fill::before { content: "\f1ee"; } + .bi-calendar-plus::before { content: "\f1ef"; } + .bi-calendar-range-fill::before { content: "\f1f0"; } + .bi-calendar-range::before { content: "\f1f1"; } + .bi-calendar-week-fill::before { content: "\f1f2"; } + .bi-calendar-week::before { content: "\f1f3"; } + .bi-calendar-x-fill::before { content: "\f1f4"; } + .bi-calendar-x::before { content: "\f1f5"; } + .bi-calendar::before { content: "\f1f6"; } + .bi-calendar2-check-fill::before { content: "\f1f7"; } + .bi-calendar2-check::before { content: "\f1f8"; } + .bi-calendar2-date-fill::before { content: "\f1f9"; } + .bi-calendar2-date::before { content: "\f1fa"; } + .bi-calendar2-day-fill::before { content: "\f1fb"; } + .bi-calendar2-day::before { content: "\f1fc"; } + .bi-calendar2-event-fill::before { content: "\f1fd"; } + .bi-calendar2-event::before { content: "\f1fe"; } + .bi-calendar2-fill::before { content: "\f1ff"; } + .bi-calendar2-minus-fill::before { content: "\f200"; } + .bi-calendar2-minus::before { content: "\f201"; } + .bi-calendar2-month-fill::before { content: "\f202"; } + .bi-calendar2-month::before { content: "\f203"; } + .bi-calendar2-plus-fill::before { content: "\f204"; } + .bi-calendar2-plus::before { content: "\f205"; } + .bi-calendar2-range-fill::before { content: "\f206"; } + .bi-calendar2-range::before { content: "\f207"; } + .bi-calendar2-week-fill::before { content: "\f208"; } + .bi-calendar2-week::before { content: "\f209"; } + .bi-calendar2-x-fill::before { content: "\f20a"; } + .bi-calendar2-x::before { content: "\f20b"; } + .bi-calendar2::before { content: "\f20c"; } + .bi-calendar3-event-fill::before { content: "\f20d"; } + .bi-calendar3-event::before { content: "\f20e"; } + .bi-calendar3-fill::before { content: "\f20f"; } + .bi-calendar3-range-fill::before { content: "\f210"; } + .bi-calendar3-range::before { content: "\f211"; } + .bi-calendar3-week-fill::before { content: "\f212"; } + .bi-calendar3-week::before { content: "\f213"; } + .bi-calendar3::before { content: "\f214"; } + .bi-calendar4-event::before { content: "\f215"; } + .bi-calendar4-range::before { content: "\f216"; } + .bi-calendar4-week::before { content: "\f217"; } + .bi-calendar4::before { content: "\f218"; } + .bi-camera-fill::before { content: "\f219"; } + .bi-camera-reels-fill::before { content: "\f21a"; } + .bi-camera-reels::before { content: "\f21b"; } + .bi-camera-video-fill::before { content: "\f21c"; } + .bi-camera-video-off-fill::before { content: "\f21d"; } + .bi-camera-video-off::before { content: "\f21e"; } + .bi-camera-video::before { content: "\f21f"; } + .bi-camera::before { content: "\f220"; } + .bi-camera2::before { content: "\f221"; } + .bi-capslock-fill::before { content: "\f222"; } + .bi-capslock::before { content: "\f223"; } + .bi-card-checklist::before { content: "\f224"; } + .bi-card-heading::before { content: "\f225"; } + .bi-card-image::before { content: "\f226"; } + .bi-card-list::before { content: "\f227"; } + .bi-card-text::before { content: "\f228"; } + .bi-caret-down-fill::before { content: "\f229"; } + .bi-caret-down-square-fill::before { content: "\f22a"; } + .bi-caret-down-square::before { content: "\f22b"; } + .bi-caret-down::before { content: "\f22c"; } + .bi-caret-left-fill::before { content: "\f22d"; } + .bi-caret-left-square-fill::before { content: "\f22e"; } + .bi-caret-left-square::before { content: "\f22f"; } + .bi-caret-left::before { content: "\f230"; } + .bi-caret-right-fill::before { content: "\f231"; } + .bi-caret-right-square-fill::before { content: "\f232"; } + .bi-caret-right-square::before { content: "\f233"; } + .bi-caret-right::before { content: "\f234"; } + .bi-caret-up-fill::before { content: "\f235"; } + .bi-caret-up-square-fill::before { content: "\f236"; } + .bi-caret-up-square::before { content: "\f237"; } + .bi-caret-up::before { content: "\f238"; } + .bi-cart-check-fill::before { content: "\f239"; } + .bi-cart-check::before { content: "\f23a"; } + .bi-cart-dash-fill::before { content: "\f23b"; } + .bi-cart-dash::before { content: "\f23c"; } + .bi-cart-fill::before { content: "\f23d"; } + .bi-cart-plus-fill::before { content: "\f23e"; } + .bi-cart-plus::before { content: "\f23f"; } + .bi-cart-x-fill::before { content: "\f240"; } + .bi-cart-x::before { content: "\f241"; } + .bi-cart::before { content: "\f242"; } + .bi-cart2::before { content: "\f243"; } + .bi-cart3::before { content: "\f244"; } + .bi-cart4::before { content: "\f245"; } + .bi-cash-stack::before { content: "\f246"; } + .bi-cash::before { content: "\f247"; } + .bi-cast::before { content: "\f248"; } + .bi-chat-dots-fill::before { content: "\f249"; } + .bi-chat-dots::before { content: "\f24a"; } + .bi-chat-fill::before { content: "\f24b"; } + .bi-chat-left-dots-fill::before { content: "\f24c"; } + .bi-chat-left-dots::before { content: "\f24d"; } + .bi-chat-left-fill::before { content: "\f24e"; } + .bi-chat-left-quote-fill::before { content: "\f24f"; } + .bi-chat-left-quote::before { content: "\f250"; } + .bi-chat-left-text-fill::before { content: "\f251"; } + .bi-chat-left-text::before { content: "\f252"; } + .bi-chat-left::before { content: "\f253"; } + .bi-chat-quote-fill::before { content: "\f254"; } + .bi-chat-quote::before { content: "\f255"; } + .bi-chat-right-dots-fill::before { content: "\f256"; } + .bi-chat-right-dots::before { content: "\f257"; } + .bi-chat-right-fill::before { content: "\f258"; } + .bi-chat-right-quote-fill::before { content: "\f259"; } + .bi-chat-right-quote::before { content: "\f25a"; } + .bi-chat-right-text-fill::before { content: "\f25b"; } + .bi-chat-right-text::before { content: "\f25c"; } + .bi-chat-right::before { content: "\f25d"; } + .bi-chat-square-dots-fill::before { content: "\f25e"; } + .bi-chat-square-dots::before { content: "\f25f"; } + .bi-chat-square-fill::before { content: "\f260"; } + .bi-chat-square-quote-fill::before { content: "\f261"; } + .bi-chat-square-quote::before { content: "\f262"; } + .bi-chat-square-text-fill::before { content: "\f263"; } + .bi-chat-square-text::before { content: "\f264"; } + .bi-chat-square::before { content: "\f265"; } + .bi-chat-text-fill::before { content: "\f266"; } + .bi-chat-text::before { content: "\f267"; } + .bi-chat::before { content: "\f268"; } + .bi-check-all::before { content: "\f269"; } + .bi-check-circle-fill::before { content: "\f26a"; } + .bi-check-circle::before { content: "\f26b"; } + .bi-check-square-fill::before { content: "\f26c"; } + .bi-check-square::before { content: "\f26d"; } + .bi-check::before { content: "\f26e"; } + .bi-check2-all::before { content: "\f26f"; } + .bi-check2-circle::before { content: "\f270"; } + .bi-check2-square::before { content: "\f271"; } + .bi-check2::before { content: "\f272"; } + .bi-chevron-bar-contract::before { content: "\f273"; } + .bi-chevron-bar-down::before { content: "\f274"; } + .bi-chevron-bar-expand::before { content: "\f275"; } + .bi-chevron-bar-left::before { content: "\f276"; } + .bi-chevron-bar-right::before { content: "\f277"; } + .bi-chevron-bar-up::before { content: "\f278"; } + .bi-chevron-compact-down::before { content: "\f279"; } + .bi-chevron-compact-left::before { content: "\f27a"; } + .bi-chevron-compact-right::before { content: "\f27b"; } + .bi-chevron-compact-up::before { content: "\f27c"; } + .bi-chevron-contract::before { content: "\f27d"; } + .bi-chevron-double-down::before { content: "\f27e"; } + .bi-chevron-double-left::before { content: "\f27f"; } + .bi-chevron-double-right::before { content: "\f280"; } + .bi-chevron-double-up::before { content: "\f281"; } + .bi-chevron-down::before { content: "\f282"; } + .bi-chevron-expand::before { content: "\f283"; } + .bi-chevron-left::before { content: "\f284"; } + .bi-chevron-right::before { content: "\f285"; } + .bi-chevron-up::before { content: "\f286"; } + .bi-circle-fill::before { content: "\f287"; } + .bi-circle-half::before { content: "\f288"; } + .bi-circle-square::before { content: "\f289"; } + .bi-circle::before { content: "\f28a"; } + .bi-clipboard-check::before { content: "\f28b"; } + .bi-clipboard-data::before { content: "\f28c"; } + .bi-clipboard-minus::before { content: "\f28d"; } + .bi-clipboard-plus::before { content: "\f28e"; } + .bi-clipboard-x::before { content: "\f28f"; } + .bi-clipboard::before { content: "\f290"; } + .bi-clock-fill::before { content: "\f291"; } + .bi-clock-history::before { content: "\f292"; } + .bi-clock::before { content: "\f293"; } + .bi-cloud-arrow-down-fill::before { content: "\f294"; } + .bi-cloud-arrow-down::before { content: "\f295"; } + .bi-cloud-arrow-up-fill::before { content: "\f296"; } + .bi-cloud-arrow-up::before { content: "\f297"; } + .bi-cloud-check-fill::before { content: "\f298"; } + .bi-cloud-check::before { content: "\f299"; } + .bi-cloud-download-fill::before { content: "\f29a"; } + .bi-cloud-download::before { content: "\f29b"; } + .bi-cloud-drizzle-fill::before { content: "\f29c"; } + .bi-cloud-drizzle::before { content: "\f29d"; } + .bi-cloud-fill::before { content: "\f29e"; } + .bi-cloud-fog-fill::before { content: "\f29f"; } + .bi-cloud-fog::before { content: "\f2a0"; } + .bi-cloud-fog2-fill::before { content: "\f2a1"; } + .bi-cloud-fog2::before { content: "\f2a2"; } + .bi-cloud-hail-fill::before { content: "\f2a3"; } + .bi-cloud-hail::before { content: "\f2a4"; } + .bi-cloud-haze-fill::before { content: "\f2a6"; } + .bi-cloud-haze::before { content: "\f2a7"; } + .bi-cloud-haze2-fill::before { content: "\f2a8"; } + .bi-cloud-lightning-fill::before { content: "\f2a9"; } + .bi-cloud-lightning-rain-fill::before { content: "\f2aa"; } + .bi-cloud-lightning-rain::before { content: "\f2ab"; } + .bi-cloud-lightning::before { content: "\f2ac"; } + .bi-cloud-minus-fill::before { content: "\f2ad"; } + .bi-cloud-minus::before { content: "\f2ae"; } + .bi-cloud-moon-fill::before { content: "\f2af"; } + .bi-cloud-moon::before { content: "\f2b0"; } + .bi-cloud-plus-fill::before { content: "\f2b1"; } + .bi-cloud-plus::before { content: "\f2b2"; } + .bi-cloud-rain-fill::before { content: "\f2b3"; } + .bi-cloud-rain-heavy-fill::before { content: "\f2b4"; } + .bi-cloud-rain-heavy::before { content: "\f2b5"; } + .bi-cloud-rain::before { content: "\f2b6"; } + .bi-cloud-slash-fill::before { content: "\f2b7"; } + .bi-cloud-slash::before { content: "\f2b8"; } + .bi-cloud-sleet-fill::before { content: "\f2b9"; } + .bi-cloud-sleet::before { content: "\f2ba"; } + .bi-cloud-snow-fill::before { content: "\f2bb"; } + .bi-cloud-snow::before { content: "\f2bc"; } + .bi-cloud-sun-fill::before { content: "\f2bd"; } + .bi-cloud-sun::before { content: "\f2be"; } + .bi-cloud-upload-fill::before { content: "\f2bf"; } + .bi-cloud-upload::before { content: "\f2c0"; } + .bi-cloud::before { content: "\f2c1"; } + .bi-clouds-fill::before { content: "\f2c2"; } + .bi-clouds::before { content: "\f2c3"; } + .bi-cloudy-fill::before { content: "\f2c4"; } + .bi-cloudy::before { content: "\f2c5"; } + .bi-code-slash::before { content: "\f2c6"; } + .bi-code-square::before { content: "\f2c7"; } + .bi-code::before { content: "\f2c8"; } + .bi-collection-fill::before { content: "\f2c9"; } + .bi-collection-play-fill::before { content: "\f2ca"; } + .bi-collection-play::before { content: "\f2cb"; } + .bi-collection::before { content: "\f2cc"; } + .bi-columns-gap::before { content: "\f2cd"; } + .bi-columns::before { content: "\f2ce"; } + .bi-command::before { content: "\f2cf"; } + .bi-compass-fill::before { content: "\f2d0"; } + .bi-compass::before { content: "\f2d1"; } + .bi-cone-striped::before { content: "\f2d2"; } + .bi-cone::before { content: "\f2d3"; } + .bi-controller::before { content: "\f2d4"; } + .bi-cpu-fill::before { content: "\f2d5"; } + .bi-cpu::before { content: "\f2d6"; } + .bi-credit-card-2-back-fill::before { content: "\f2d7"; } + .bi-credit-card-2-back::before { content: "\f2d8"; } + .bi-credit-card-2-front-fill::before { content: "\f2d9"; } + .bi-credit-card-2-front::before { content: "\f2da"; } + .bi-credit-card-fill::before { content: "\f2db"; } + .bi-credit-card::before { content: "\f2dc"; } + .bi-crop::before { content: "\f2dd"; } + .bi-cup-fill::before { content: "\f2de"; } + .bi-cup-straw::before { content: "\f2df"; } + .bi-cup::before { content: "\f2e0"; } + .bi-cursor-fill::before { content: "\f2e1"; } + .bi-cursor-text::before { content: "\f2e2"; } + .bi-cursor::before { content: "\f2e3"; } + .bi-dash-circle-dotted::before { content: "\f2e4"; } + .bi-dash-circle-fill::before { content: "\f2e5"; } + .bi-dash-circle::before { content: "\f2e6"; } + .bi-dash-square-dotted::before { content: "\f2e7"; } + .bi-dash-square-fill::before { content: "\f2e8"; } + .bi-dash-square::before { content: "\f2e9"; } + .bi-dash::before { content: "\f2ea"; } + .bi-diagram-2-fill::before { content: "\f2eb"; } + .bi-diagram-2::before { content: "\f2ec"; } + .bi-diagram-3-fill::before { content: "\f2ed"; } + .bi-diagram-3::before { content: "\f2ee"; } + .bi-diamond-fill::before { content: "\f2ef"; } + .bi-diamond-half::before { content: "\f2f0"; } + .bi-diamond::before { content: "\f2f1"; } + .bi-dice-1-fill::before { content: "\f2f2"; } + .bi-dice-1::before { content: "\f2f3"; } + .bi-dice-2-fill::before { content: "\f2f4"; } + .bi-dice-2::before { content: "\f2f5"; } + .bi-dice-3-fill::before { content: "\f2f6"; } + .bi-dice-3::before { content: "\f2f7"; } + .bi-dice-4-fill::before { content: "\f2f8"; } + .bi-dice-4::before { content: "\f2f9"; } + .bi-dice-5-fill::before { content: "\f2fa"; } + .bi-dice-5::before { content: "\f2fb"; } + .bi-dice-6-fill::before { content: "\f2fc"; } + .bi-dice-6::before { content: "\f2fd"; } + .bi-disc-fill::before { content: "\f2fe"; } + .bi-disc::before { content: "\f2ff"; } + .bi-discord::before { content: "\f300"; } + .bi-display-fill::before { content: "\f301"; } + .bi-display::before { content: "\f302"; } + .bi-distribute-horizontal::before { content: "\f303"; } + .bi-distribute-vertical::before { content: "\f304"; } + .bi-door-closed-fill::before { content: "\f305"; } + .bi-door-closed::before { content: "\f306"; } + .bi-door-open-fill::before { content: "\f307"; } + .bi-door-open::before { content: "\f308"; } + .bi-dot::before { content: "\f309"; } + .bi-download::before { content: "\f30a"; } + .bi-droplet-fill::before { content: "\f30b"; } + .bi-droplet-half::before { content: "\f30c"; } + .bi-droplet::before { content: "\f30d"; } + .bi-earbuds::before { content: "\f30e"; } + .bi-easel-fill::before { content: "\f30f"; } + .bi-easel::before { content: "\f310"; } + .bi-egg-fill::before { content: "\f311"; } + .bi-egg-fried::before { content: "\f312"; } + .bi-egg::before { content: "\f313"; } + .bi-eject-fill::before { content: "\f314"; } + .bi-eject::before { content: "\f315"; } + .bi-emoji-angry-fill::before { content: "\f316"; } + .bi-emoji-angry::before { content: "\f317"; } + .bi-emoji-dizzy-fill::before { content: "\f318"; } + .bi-emoji-dizzy::before { content: "\f319"; } + .bi-emoji-expressionless-fill::before { content: "\f31a"; } + .bi-emoji-expressionless::before { content: "\f31b"; } + .bi-emoji-frown-fill::before { content: "\f31c"; } + .bi-emoji-frown::before { content: "\f31d"; } + .bi-emoji-heart-eyes-fill::before { content: "\f31e"; } + .bi-emoji-heart-eyes::before { content: "\f31f"; } + .bi-emoji-laughing-fill::before { content: "\f320"; } + .bi-emoji-laughing::before { content: "\f321"; } + .bi-emoji-neutral-fill::before { content: "\f322"; } + .bi-emoji-neutral::before { content: "\f323"; } + .bi-emoji-smile-fill::before { content: "\f324"; } + .bi-emoji-smile-upside-down-fill::before { content: "\f325"; } + .bi-emoji-smile-upside-down::before { content: "\f326"; } + .bi-emoji-smile::before { content: "\f327"; } + .bi-emoji-sunglasses-fill::before { content: "\f328"; } + .bi-emoji-sunglasses::before { content: "\f329"; } + .bi-emoji-wink-fill::before { content: "\f32a"; } + .bi-emoji-wink::before { content: "\f32b"; } + .bi-envelope-fill::before { content: "\f32c"; } + .bi-envelope-open-fill::before { content: "\f32d"; } + .bi-envelope-open::before { content: "\f32e"; } + .bi-envelope::before { content: "\f32f"; } + .bi-eraser-fill::before { content: "\f330"; } + .bi-eraser::before { content: "\f331"; } + .bi-exclamation-circle-fill::before { content: "\f332"; } + .bi-exclamation-circle::before { content: "\f333"; } + .bi-exclamation-diamond-fill::before { content: "\f334"; } + .bi-exclamation-diamond::before { content: "\f335"; } + .bi-exclamation-octagon-fill::before { content: "\f336"; } + .bi-exclamation-octagon::before { content: "\f337"; } + .bi-exclamation-square-fill::before { content: "\f338"; } + .bi-exclamation-square::before { content: "\f339"; } + .bi-exclamation-triangle-fill::before { content: "\f33a"; } + .bi-exclamation-triangle::before { content: "\f33b"; } + .bi-exclamation::before { content: "\f33c"; } + .bi-exclude::before { content: "\f33d"; } + .bi-eye-fill::before { content: "\f33e"; } + .bi-eye-slash-fill::before { content: "\f33f"; } + .bi-eye-slash::before { content: "\f340"; } + .bi-eye::before { content: "\f341"; } + .bi-eyedropper::before { content: "\f342"; } + .bi-eyeglasses::before { content: "\f343"; } + .bi-facebook::before { content: "\f344"; } + .bi-file-arrow-down-fill::before { content: "\f345"; } + .bi-file-arrow-down::before { content: "\f346"; } + .bi-file-arrow-up-fill::before { content: "\f347"; } + .bi-file-arrow-up::before { content: "\f348"; } + .bi-file-bar-graph-fill::before { content: "\f349"; } + .bi-file-bar-graph::before { content: "\f34a"; } + .bi-file-binary-fill::before { content: "\f34b"; } + .bi-file-binary::before { content: "\f34c"; } + .bi-file-break-fill::before { content: "\f34d"; } + .bi-file-break::before { content: "\f34e"; } + .bi-file-check-fill::before { content: "\f34f"; } + .bi-file-check::before { content: "\f350"; } + .bi-file-code-fill::before { content: "\f351"; } + .bi-file-code::before { content: "\f352"; } + .bi-file-diff-fill::before { content: "\f353"; } + .bi-file-diff::before { content: "\f354"; } + .bi-file-earmark-arrow-down-fill::before { content: "\f355"; } + .bi-file-earmark-arrow-down::before { content: "\f356"; } + .bi-file-earmark-arrow-up-fill::before { content: "\f357"; } + .bi-file-earmark-arrow-up::before { content: "\f358"; } + .bi-file-earmark-bar-graph-fill::before { content: "\f359"; } + .bi-file-earmark-bar-graph::before { content: "\f35a"; } + .bi-file-earmark-binary-fill::before { content: "\f35b"; } + .bi-file-earmark-binary::before { content: "\f35c"; } + .bi-file-earmark-break-fill::before { content: "\f35d"; } + .bi-file-earmark-break::before { content: "\f35e"; } + .bi-file-earmark-check-fill::before { content: "\f35f"; } + .bi-file-earmark-check::before { content: "\f360"; } + .bi-file-earmark-code-fill::before { content: "\f361"; } + .bi-file-earmark-code::before { content: "\f362"; } + .bi-file-earmark-diff-fill::before { content: "\f363"; } + .bi-file-earmark-diff::before { content: "\f364"; } + .bi-file-earmark-easel-fill::before { content: "\f365"; } + .bi-file-earmark-easel::before { content: "\f366"; } + .bi-file-earmark-excel-fill::before { content: "\f367"; } + .bi-file-earmark-excel::before { content: "\f368"; } + .bi-file-earmark-fill::before { content: "\f369"; } + .bi-file-earmark-font-fill::before { content: "\f36a"; } + .bi-file-earmark-font::before { content: "\f36b"; } + .bi-file-earmark-image-fill::before { content: "\f36c"; } + .bi-file-earmark-image::before { content: "\f36d"; } + .bi-file-earmark-lock-fill::before { content: "\f36e"; } + .bi-file-earmark-lock::before { content: "\f36f"; } + .bi-file-earmark-lock2-fill::before { content: "\f370"; } + .bi-file-earmark-lock2::before { content: "\f371"; } + .bi-file-earmark-medical-fill::before { content: "\f372"; } + .bi-file-earmark-medical::before { content: "\f373"; } + .bi-file-earmark-minus-fill::before { content: "\f374"; } + .bi-file-earmark-minus::before { content: "\f375"; } + .bi-file-earmark-music-fill::before { content: "\f376"; } + .bi-file-earmark-music::before { content: "\f377"; } + .bi-file-earmark-person-fill::before { content: "\f378"; } + .bi-file-earmark-person::before { content: "\f379"; } + .bi-file-earmark-play-fill::before { content: "\f37a"; } + .bi-file-earmark-play::before { content: "\f37b"; } + .bi-file-earmark-plus-fill::before { content: "\f37c"; } + .bi-file-earmark-plus::before { content: "\f37d"; } + .bi-file-earmark-post-fill::before { content: "\f37e"; } + .bi-file-earmark-post::before { content: "\f37f"; } + .bi-file-earmark-ppt-fill::before { content: "\f380"; } + .bi-file-earmark-ppt::before { content: "\f381"; } + .bi-file-earmark-richtext-fill::before { content: "\f382"; } + .bi-file-earmark-richtext::before { content: "\f383"; } + .bi-file-earmark-ruled-fill::before { content: "\f384"; } + .bi-file-earmark-ruled::before { content: "\f385"; } + .bi-file-earmark-slides-fill::before { content: "\f386"; } + .bi-file-earmark-slides::before { content: "\f387"; } + .bi-file-earmark-spreadsheet-fill::before { content: "\f388"; } + .bi-file-earmark-spreadsheet::before { content: "\f389"; } + .bi-file-earmark-text-fill::before { content: "\f38a"; } + .bi-file-earmark-text::before { content: "\f38b"; } + .bi-file-earmark-word-fill::before { content: "\f38c"; } + .bi-file-earmark-word::before { content: "\f38d"; } + .bi-file-earmark-x-fill::before { content: "\f38e"; } + .bi-file-earmark-x::before { content: "\f38f"; } + .bi-file-earmark-zip-fill::before { content: "\f390"; } + .bi-file-earmark-zip::before { content: "\f391"; } + .bi-file-earmark::before { content: "\f392"; } + .bi-file-easel-fill::before { content: "\f393"; } + .bi-file-easel::before { content: "\f394"; } + .bi-file-excel-fill::before { content: "\f395"; } + .bi-file-excel::before { content: "\f396"; } + .bi-file-fill::before { content: "\f397"; } + .bi-file-font-fill::before { content: "\f398"; } + .bi-file-font::before { content: "\f399"; } + .bi-file-image-fill::before { content: "\f39a"; } + .bi-file-image::before { content: "\f39b"; } + .bi-file-lock-fill::before { content: "\f39c"; } + .bi-file-lock::before { content: "\f39d"; } + .bi-file-lock2-fill::before { content: "\f39e"; } + .bi-file-lock2::before { content: "\f39f"; } + .bi-file-medical-fill::before { content: "\f3a0"; } + .bi-file-medical::before { content: "\f3a1"; } + .bi-file-minus-fill::before { content: "\f3a2"; } + .bi-file-minus::before { content: "\f3a3"; } + .bi-file-music-fill::before { content: "\f3a4"; } + .bi-file-music::before { content: "\f3a5"; } + .bi-file-person-fill::before { content: "\f3a6"; } + .bi-file-person::before { content: "\f3a7"; } + .bi-file-play-fill::before { content: "\f3a8"; } + .bi-file-play::before { content: "\f3a9"; } + .bi-file-plus-fill::before { content: "\f3aa"; } + .bi-file-plus::before { content: "\f3ab"; } + .bi-file-post-fill::before { content: "\f3ac"; } + .bi-file-post::before { content: "\f3ad"; } + .bi-file-ppt-fill::before { content: "\f3ae"; } + .bi-file-ppt::before { content: "\f3af"; } + .bi-file-richtext-fill::before { content: "\f3b0"; } + .bi-file-richtext::before { content: "\f3b1"; } + .bi-file-ruled-fill::before { content: "\f3b2"; } + .bi-file-ruled::before { content: "\f3b3"; } + .bi-file-slides-fill::before { content: "\f3b4"; } + .bi-file-slides::before { content: "\f3b5"; } + .bi-file-spreadsheet-fill::before { content: "\f3b6"; } + .bi-file-spreadsheet::before { content: "\f3b7"; } + .bi-file-text-fill::before { content: "\f3b8"; } + .bi-file-text::before { content: "\f3b9"; } + .bi-file-word-fill::before { content: "\f3ba"; } + .bi-file-word::before { content: "\f3bb"; } + .bi-file-x-fill::before { content: "\f3bc"; } + .bi-file-x::before { content: "\f3bd"; } + .bi-file-zip-fill::before { content: "\f3be"; } + .bi-file-zip::before { content: "\f3bf"; } + .bi-file::before { content: "\f3c0"; } + .bi-files-alt::before { content: "\f3c1"; } + .bi-files::before { content: "\f3c2"; } + .bi-film::before { content: "\f3c3"; } + .bi-filter-circle-fill::before { content: "\f3c4"; } + .bi-filter-circle::before { content: "\f3c5"; } + .bi-filter-left::before { content: "\f3c6"; } + .bi-filter-right::before { content: "\f3c7"; } + .bi-filter-square-fill::before { content: "\f3c8"; } + .bi-filter-square::before { content: "\f3c9"; } + .bi-filter::before { content: "\f3ca"; } + .bi-flag-fill::before { content: "\f3cb"; } + .bi-flag::before { content: "\f3cc"; } + .bi-flower1::before { content: "\f3cd"; } + .bi-flower2::before { content: "\f3ce"; } + .bi-flower3::before { content: "\f3cf"; } + .bi-folder-check::before { content: "\f3d0"; } + .bi-folder-fill::before { content: "\f3d1"; } + .bi-folder-minus::before { content: "\f3d2"; } + .bi-folder-plus::before { content: "\f3d3"; } + .bi-folder-symlink-fill::before { content: "\f3d4"; } + .bi-folder-symlink::before { content: "\f3d5"; } + .bi-folder-x::before { content: "\f3d6"; } + .bi-folder::before { content: "\f3d7"; } + .bi-folder2-open::before { content: "\f3d8"; } + .bi-folder2::before { content: "\f3d9"; } + .bi-fonts::before { content: "\f3da"; } + .bi-forward-fill::before { content: "\f3db"; } + .bi-forward::before { content: "\f3dc"; } + .bi-front::before { content: "\f3dd"; } + .bi-fullscreen-exit::before { content: "\f3de"; } + .bi-fullscreen::before { content: "\f3df"; } + .bi-funnel-fill::before { content: "\f3e0"; } + .bi-funnel::before { content: "\f3e1"; } + .bi-gear-fill::before { content: "\f3e2"; } + .bi-gear-wide-connected::before { content: "\f3e3"; } + .bi-gear-wide::before { content: "\f3e4"; } + .bi-gear::before { content: "\f3e5"; } + .bi-gem::before { content: "\f3e6"; } + .bi-geo-alt-fill::before { content: "\f3e7"; } + .bi-geo-alt::before { content: "\f3e8"; } + .bi-geo-fill::before { content: "\f3e9"; } + .bi-geo::before { content: "\f3ea"; } + .bi-gift-fill::before { content: "\f3eb"; } + .bi-gift::before { content: "\f3ec"; } + .bi-github::before { content: "\f3ed"; } + .bi-globe::before { content: "\f3ee"; } + .bi-globe2::before { content: "\f3ef"; } + .bi-google::before { content: "\f3f0"; } + .bi-graph-down::before { content: "\f3f1"; } + .bi-graph-up::before { content: "\f3f2"; } + .bi-grid-1x2-fill::before { content: "\f3f3"; } + .bi-grid-1x2::before { content: "\f3f4"; } + .bi-grid-3x2-gap-fill::before { content: "\f3f5"; } + .bi-grid-3x2-gap::before { content: "\f3f6"; } + .bi-grid-3x2::before { content: "\f3f7"; } + .bi-grid-3x3-gap-fill::before { content: "\f3f8"; } + .bi-grid-3x3-gap::before { content: "\f3f9"; } + .bi-grid-3x3::before { content: "\f3fa"; } + .bi-grid-fill::before { content: "\f3fb"; } + .bi-grid::before { content: "\f3fc"; } + .bi-grip-horizontal::before { content: "\f3fd"; } + .bi-grip-vertical::before { content: "\f3fe"; } + .bi-hammer::before { content: "\f3ff"; } + .bi-hand-index-fill::before { content: "\f400"; } + .bi-hand-index-thumb-fill::before { content: "\f401"; } + .bi-hand-index-thumb::before { content: "\f402"; } + .bi-hand-index::before { content: "\f403"; } + .bi-hand-thumbs-down-fill::before { content: "\f404"; } + .bi-hand-thumbs-down::before { content: "\f405"; } + .bi-hand-thumbs-up-fill::before { content: "\f406"; } + .bi-hand-thumbs-up::before { content: "\f407"; } + .bi-handbag-fill::before { content: "\f408"; } + .bi-handbag::before { content: "\f409"; } + .bi-hash::before { content: "\f40a"; } + .bi-hdd-fill::before { content: "\f40b"; } + .bi-hdd-network-fill::before { content: "\f40c"; } + .bi-hdd-network::before { content: "\f40d"; } + .bi-hdd-rack-fill::before { content: "\f40e"; } + .bi-hdd-rack::before { content: "\f40f"; } + .bi-hdd-stack-fill::before { content: "\f410"; } + .bi-hdd-stack::before { content: "\f411"; } + .bi-hdd::before { content: "\f412"; } + .bi-headphones::before { content: "\f413"; } + .bi-headset::before { content: "\f414"; } + .bi-heart-fill::before { content: "\f415"; } + .bi-heart-half::before { content: "\f416"; } + .bi-heart::before { content: "\f417"; } + .bi-heptagon-fill::before { content: "\f418"; } + .bi-heptagon-half::before { content: "\f419"; } + .bi-heptagon::before { content: "\f41a"; } + .bi-hexagon-fill::before { content: "\f41b"; } + .bi-hexagon-half::before { content: "\f41c"; } + .bi-hexagon::before { content: "\f41d"; } + .bi-hourglass-bottom::before { content: "\f41e"; } + .bi-hourglass-split::before { content: "\f41f"; } + .bi-hourglass-top::before { content: "\f420"; } + .bi-hourglass::before { content: "\f421"; } + .bi-house-door-fill::before { content: "\f422"; } + .bi-house-door::before { content: "\f423"; } + .bi-house-fill::before { content: "\f424"; } + .bi-house::before { content: "\f425"; } + .bi-hr::before { content: "\f426"; } + .bi-hurricane::before { content: "\f427"; } + .bi-image-alt::before { content: "\f428"; } + .bi-image-fill::before { content: "\f429"; } + .bi-image::before { content: "\f42a"; } + .bi-images::before { content: "\f42b"; } + .bi-inbox-fill::before { content: "\f42c"; } + .bi-inbox::before { content: "\f42d"; } + .bi-inboxes-fill::before { content: "\f42e"; } + .bi-inboxes::before { content: "\f42f"; } + .bi-info-circle-fill::before { content: "\f430"; } + .bi-info-circle::before { content: "\f431"; } + .bi-info-square-fill::before { content: "\f432"; } + .bi-info-square::before { content: "\f433"; } + .bi-info::before { content: "\f434"; } + .bi-input-cursor-text::before { content: "\f435"; } + .bi-input-cursor::before { content: "\f436"; } + .bi-instagram::before { content: "\f437"; } + .bi-intersect::before { content: "\f438"; } + .bi-journal-album::before { content: "\f439"; } + .bi-journal-arrow-down::before { content: "\f43a"; } + .bi-journal-arrow-up::before { content: "\f43b"; } + .bi-journal-bookmark-fill::before { content: "\f43c"; } + .bi-journal-bookmark::before { content: "\f43d"; } + .bi-journal-check::before { content: "\f43e"; } + .bi-journal-code::before { content: "\f43f"; } + .bi-journal-medical::before { content: "\f440"; } + .bi-journal-minus::before { content: "\f441"; } + .bi-journal-plus::before { content: "\f442"; } + .bi-journal-richtext::before { content: "\f443"; } + .bi-journal-text::before { content: "\f444"; } + .bi-journal-x::before { content: "\f445"; } + .bi-journal::before { content: "\f446"; } + .bi-journals::before { content: "\f447"; } + .bi-joystick::before { content: "\f448"; } + .bi-justify-left::before { content: "\f449"; } + .bi-justify-right::before { content: "\f44a"; } + .bi-justify::before { content: "\f44b"; } + .bi-kanban-fill::before { content: "\f44c"; } + .bi-kanban::before { content: "\f44d"; } + .bi-key-fill::before { content: "\f44e"; } + .bi-key::before { content: "\f44f"; } + .bi-keyboard-fill::before { content: "\f450"; } + .bi-keyboard::before { content: "\f451"; } + .bi-ladder::before { content: "\f452"; } + .bi-lamp-fill::before { content: "\f453"; } + .bi-lamp::before { content: "\f454"; } + .bi-laptop-fill::before { content: "\f455"; } + .bi-laptop::before { content: "\f456"; } + .bi-layer-backward::before { content: "\f457"; } + .bi-layer-forward::before { content: "\f458"; } + .bi-layers-fill::before { content: "\f459"; } + .bi-layers-half::before { content: "\f45a"; } + .bi-layers::before { content: "\f45b"; } + .bi-layout-sidebar-inset-reverse::before { content: "\f45c"; } + .bi-layout-sidebar-inset::before { content: "\f45d"; } + .bi-layout-sidebar-reverse::before { content: "\f45e"; } + .bi-layout-sidebar::before { content: "\f45f"; } + .bi-layout-split::before { content: "\f460"; } + .bi-layout-text-sidebar-reverse::before { content: "\f461"; } + .bi-layout-text-sidebar::before { content: "\f462"; } + .bi-layout-text-window-reverse::before { content: "\f463"; } + .bi-layout-text-window::before { content: "\f464"; } + .bi-layout-three-columns::before { content: "\f465"; } + .bi-layout-wtf::before { content: "\f466"; } + .bi-life-preserver::before { content: "\f467"; } + .bi-lightbulb-fill::before { content: "\f468"; } + .bi-lightbulb-off-fill::before { content: "\f469"; } + .bi-lightbulb-off::before { content: "\f46a"; } + .bi-lightbulb::before { content: "\f46b"; } + .bi-lightning-charge-fill::before { content: "\f46c"; } + .bi-lightning-charge::before { content: "\f46d"; } + .bi-lightning-fill::before { content: "\f46e"; } + .bi-lightning::before { content: "\f46f"; } + .bi-link-45deg::before { content: "\f470"; } + .bi-link::before { content: "\f471"; } + .bi-linkedin::before { content: "\f472"; } + .bi-list-check::before { content: "\f473"; } + .bi-list-nested::before { content: "\f474"; } + .bi-list-ol::before { content: "\f475"; } + .bi-list-stars::before { content: "\f476"; } + .bi-list-task::before { content: "\f477"; } + .bi-list-ul::before { content: "\f478"; } + .bi-list::before { content: "\f479"; } + .bi-lock-fill::before { content: "\f47a"; } + .bi-lock::before { content: "\f47b"; } + .bi-mailbox::before { content: "\f47c"; } + .bi-mailbox2::before { content: "\f47d"; } + .bi-map-fill::before { content: "\f47e"; } + .bi-map::before { content: "\f47f"; } + .bi-markdown-fill::before { content: "\f480"; } + .bi-markdown::before { content: "\f481"; } + .bi-mask::before { content: "\f482"; } + .bi-megaphone-fill::before { content: "\f483"; } + .bi-megaphone::before { content: "\f484"; } + .bi-menu-app-fill::before { content: "\f485"; } + .bi-menu-app::before { content: "\f486"; } + .bi-menu-button-fill::before { content: "\f487"; } + .bi-menu-button-wide-fill::before { content: "\f488"; } + .bi-menu-button-wide::before { content: "\f489"; } + .bi-menu-button::before { content: "\f48a"; } + .bi-menu-down::before { content: "\f48b"; } + .bi-menu-up::before { content: "\f48c"; } + .bi-mic-fill::before { content: "\f48d"; } + .bi-mic-mute-fill::before { content: "\f48e"; } + .bi-mic-mute::before { content: "\f48f"; } + .bi-mic::before { content: "\f490"; } + .bi-minecart-loaded::before { content: "\f491"; } + .bi-minecart::before { content: "\f492"; } + .bi-moisture::before { content: "\f493"; } + .bi-moon-fill::before { content: "\f494"; } + .bi-moon-stars-fill::before { content: "\f495"; } + .bi-moon-stars::before { content: "\f496"; } + .bi-moon::before { content: "\f497"; } + .bi-mouse-fill::before { content: "\f498"; } + .bi-mouse::before { content: "\f499"; } + .bi-mouse2-fill::before { content: "\f49a"; } + .bi-mouse2::before { content: "\f49b"; } + .bi-mouse3-fill::before { content: "\f49c"; } + .bi-mouse3::before { content: "\f49d"; } + .bi-music-note-beamed::before { content: "\f49e"; } + .bi-music-note-list::before { content: "\f49f"; } + .bi-music-note::before { content: "\f4a0"; } + .bi-music-player-fill::before { content: "\f4a1"; } + .bi-music-player::before { content: "\f4a2"; } + .bi-newspaper::before { content: "\f4a3"; } + .bi-node-minus-fill::before { content: "\f4a4"; } + .bi-node-minus::before { content: "\f4a5"; } + .bi-node-plus-fill::before { content: "\f4a6"; } + .bi-node-plus::before { content: "\f4a7"; } + .bi-nut-fill::before { content: "\f4a8"; } + .bi-nut::before { content: "\f4a9"; } + .bi-octagon-fill::before { content: "\f4aa"; } + .bi-octagon-half::before { content: "\f4ab"; } + .bi-octagon::before { content: "\f4ac"; } + .bi-option::before { content: "\f4ad"; } + .bi-outlet::before { content: "\f4ae"; } + .bi-paint-bucket::before { content: "\f4af"; } + .bi-palette-fill::before { content: "\f4b0"; } + .bi-palette::before { content: "\f4b1"; } + .bi-palette2::before { content: "\f4b2"; } + .bi-paperclip::before { content: "\f4b3"; } + .bi-paragraph::before { content: "\f4b4"; } + .bi-patch-check-fill::before { content: "\f4b5"; } + .bi-patch-check::before { content: "\f4b6"; } + .bi-patch-exclamation-fill::before { content: "\f4b7"; } + .bi-patch-exclamation::before { content: "\f4b8"; } + .bi-patch-minus-fill::before { content: "\f4b9"; } + .bi-patch-minus::before { content: "\f4ba"; } + .bi-patch-plus-fill::before { content: "\f4bb"; } + .bi-patch-plus::before { content: "\f4bc"; } + .bi-patch-question-fill::before { content: "\f4bd"; } + .bi-patch-question::before { content: "\f4be"; } + .bi-pause-btn-fill::before { content: "\f4bf"; } + .bi-pause-btn::before { content: "\f4c0"; } + .bi-pause-circle-fill::before { content: "\f4c1"; } + .bi-pause-circle::before { content: "\f4c2"; } + .bi-pause-fill::before { content: "\f4c3"; } + .bi-pause::before { content: "\f4c4"; } + .bi-peace-fill::before { content: "\f4c5"; } + .bi-peace::before { content: "\f4c6"; } + .bi-pen-fill::before { content: "\f4c7"; } + .bi-pen::before { content: "\f4c8"; } + .bi-pencil-fill::before { content: "\f4c9"; } + .bi-pencil-square::before { content: "\f4ca"; } + .bi-pencil::before { content: "\f4cb"; } + .bi-pentagon-fill::before { content: "\f4cc"; } + .bi-pentagon-half::before { content: "\f4cd"; } + .bi-pentagon::before { content: "\f4ce"; } + .bi-people-fill::before { content: "\f4cf"; } + .bi-people::before { content: "\f4d0"; } + .bi-percent::before { content: "\f4d1"; } + .bi-person-badge-fill::before { content: "\f4d2"; } + .bi-person-badge::before { content: "\f4d3"; } + .bi-person-bounding-box::before { content: "\f4d4"; } + .bi-person-check-fill::before { content: "\f4d5"; } + .bi-person-check::before { content: "\f4d6"; } + .bi-person-circle::before { content: "\f4d7"; } + .bi-person-dash-fill::before { content: "\f4d8"; } + .bi-person-dash::before { content: "\f4d9"; } + .bi-person-fill::before { content: "\f4da"; } + .bi-person-lines-fill::before { content: "\f4db"; } + .bi-person-plus-fill::before { content: "\f4dc"; } + .bi-person-plus::before { content: "\f4dd"; } + .bi-person-square::before { content: "\f4de"; } + .bi-person-x-fill::before { content: "\f4df"; } + .bi-person-x::before { content: "\f4e0"; } + .bi-person::before { content: "\f4e1"; } + .bi-phone-fill::before { content: "\f4e2"; } + .bi-phone-landscape-fill::before { content: "\f4e3"; } + .bi-phone-landscape::before { content: "\f4e4"; } + .bi-phone-vibrate-fill::before { content: "\f4e5"; } + .bi-phone-vibrate::before { content: "\f4e6"; } + .bi-phone::before { content: "\f4e7"; } + .bi-pie-chart-fill::before { content: "\f4e8"; } + .bi-pie-chart::before { content: "\f4e9"; } + .bi-pin-angle-fill::before { content: "\f4ea"; } + .bi-pin-angle::before { content: "\f4eb"; } + .bi-pin-fill::before { content: "\f4ec"; } + .bi-pin::before { content: "\f4ed"; } + .bi-pip-fill::before { content: "\f4ee"; } + .bi-pip::before { content: "\f4ef"; } + .bi-play-btn-fill::before { content: "\f4f0"; } + .bi-play-btn::before { content: "\f4f1"; } + .bi-play-circle-fill::before { content: "\f4f2"; } + .bi-play-circle::before { content: "\f4f3"; } + .bi-play-fill::before { content: "\f4f4"; } + .bi-play::before { content: "\f4f5"; } + .bi-plug-fill::before { content: "\f4f6"; } + .bi-plug::before { content: "\f4f7"; } + .bi-plus-circle-dotted::before { content: "\f4f8"; } + .bi-plus-circle-fill::before { content: "\f4f9"; } + .bi-plus-circle::before { content: "\f4fa"; } + .bi-plus-square-dotted::before { content: "\f4fb"; } + .bi-plus-square-fill::before { content: "\f4fc"; } + .bi-plus-square::before { content: "\f4fd"; } + .bi-plus::before { content: "\f4fe"; } + .bi-power::before { content: "\f4ff"; } + .bi-printer-fill::before { content: "\f500"; } + .bi-printer::before { content: "\f501"; } + .bi-puzzle-fill::before { content: "\f502"; } + .bi-puzzle::before { content: "\f503"; } + .bi-question-circle-fill::before { content: "\f504"; } + .bi-question-circle::before { content: "\f505"; } + .bi-question-diamond-fill::before { content: "\f506"; } + .bi-question-diamond::before { content: "\f507"; } + .bi-question-octagon-fill::before { content: "\f508"; } + .bi-question-octagon::before { content: "\f509"; } + .bi-question-square-fill::before { content: "\f50a"; } + .bi-question-square::before { content: "\f50b"; } + .bi-question::before { content: "\f50c"; } + .bi-rainbow::before { content: "\f50d"; } + .bi-receipt-cutoff::before { content: "\f50e"; } + .bi-receipt::before { content: "\f50f"; } + .bi-reception-0::before { content: "\f510"; } + .bi-reception-1::before { content: "\f511"; } + .bi-reception-2::before { content: "\f512"; } + .bi-reception-3::before { content: "\f513"; } + .bi-reception-4::before { content: "\f514"; } + .bi-record-btn-fill::before { content: "\f515"; } + .bi-record-btn::before { content: "\f516"; } + .bi-record-circle-fill::before { content: "\f517"; } + .bi-record-circle::before { content: "\f518"; } + .bi-record-fill::before { content: "\f519"; } + .bi-record::before { content: "\f51a"; } + .bi-record2-fill::before { content: "\f51b"; } + .bi-record2::before { content: "\f51c"; } + .bi-reply-all-fill::before { content: "\f51d"; } + .bi-reply-all::before { content: "\f51e"; } + .bi-reply-fill::before { content: "\f51f"; } + .bi-reply::before { content: "\f520"; } + .bi-rss-fill::before { content: "\f521"; } + .bi-rss::before { content: "\f522"; } + .bi-rulers::before { content: "\f523"; } + .bi-save-fill::before { content: "\f524"; } + .bi-save::before { content: "\f525"; } + .bi-save2-fill::before { content: "\f526"; } + .bi-save2::before { content: "\f527"; } + .bi-scissors::before { content: "\f528"; } + .bi-screwdriver::before { content: "\f529"; } + .bi-search::before { content: "\f52a"; } + .bi-segmented-nav::before { content: "\f52b"; } + .bi-server::before { content: "\f52c"; } + .bi-share-fill::before { content: "\f52d"; } + .bi-share::before { content: "\f52e"; } + .bi-shield-check::before { content: "\f52f"; } + .bi-shield-exclamation::before { content: "\f530"; } + .bi-shield-fill-check::before { content: "\f531"; } + .bi-shield-fill-exclamation::before { content: "\f532"; } + .bi-shield-fill-minus::before { content: "\f533"; } + .bi-shield-fill-plus::before { content: "\f534"; } + .bi-shield-fill-x::before { content: "\f535"; } + .bi-shield-fill::before { content: "\f536"; } + .bi-shield-lock-fill::before { content: "\f537"; } + .bi-shield-lock::before { content: "\f538"; } + .bi-shield-minus::before { content: "\f539"; } + .bi-shield-plus::before { content: "\f53a"; } + .bi-shield-shaded::before { content: "\f53b"; } + .bi-shield-slash-fill::before { content: "\f53c"; } + .bi-shield-slash::before { content: "\f53d"; } + .bi-shield-x::before { content: "\f53e"; } + .bi-shield::before { content: "\f53f"; } + .bi-shift-fill::before { content: "\f540"; } + .bi-shift::before { content: "\f541"; } + .bi-shop-window::before { content: "\f542"; } + .bi-shop::before { content: "\f543"; } + .bi-shuffle::before { content: "\f544"; } + .bi-signpost-2-fill::before { content: "\f545"; } + .bi-signpost-2::before { content: "\f546"; } + .bi-signpost-fill::before { content: "\f547"; } + .bi-signpost-split-fill::before { content: "\f548"; } + .bi-signpost-split::before { content: "\f549"; } + .bi-signpost::before { content: "\f54a"; } + .bi-sim-fill::before { content: "\f54b"; } + .bi-sim::before { content: "\f54c"; } + .bi-skip-backward-btn-fill::before { content: "\f54d"; } + .bi-skip-backward-btn::before { content: "\f54e"; } + .bi-skip-backward-circle-fill::before { content: "\f54f"; } + .bi-skip-backward-circle::before { content: "\f550"; } + .bi-skip-backward-fill::before { content: "\f551"; } + .bi-skip-backward::before { content: "\f552"; } + .bi-skip-end-btn-fill::before { content: "\f553"; } + .bi-skip-end-btn::before { content: "\f554"; } + .bi-skip-end-circle-fill::before { content: "\f555"; } + .bi-skip-end-circle::before { content: "\f556"; } + .bi-skip-end-fill::before { content: "\f557"; } + .bi-skip-end::before { content: "\f558"; } + .bi-skip-forward-btn-fill::before { content: "\f559"; } + .bi-skip-forward-btn::before { content: "\f55a"; } + .bi-skip-forward-circle-fill::before { content: "\f55b"; } + .bi-skip-forward-circle::before { content: "\f55c"; } + .bi-skip-forward-fill::before { content: "\f55d"; } + .bi-skip-forward::before { content: "\f55e"; } + .bi-skip-start-btn-fill::before { content: "\f55f"; } + .bi-skip-start-btn::before { content: "\f560"; } + .bi-skip-start-circle-fill::before { content: "\f561"; } + .bi-skip-start-circle::before { content: "\f562"; } + .bi-skip-start-fill::before { content: "\f563"; } + .bi-skip-start::before { content: "\f564"; } + .bi-slack::before { content: "\f565"; } + .bi-slash-circle-fill::before { content: "\f566"; } + .bi-slash-circle::before { content: "\f567"; } + .bi-slash-square-fill::before { content: "\f568"; } + .bi-slash-square::before { content: "\f569"; } + .bi-slash::before { content: "\f56a"; } + .bi-sliders::before { content: "\f56b"; } + .bi-smartwatch::before { content: "\f56c"; } + .bi-snow::before { content: "\f56d"; } + .bi-snow2::before { content: "\f56e"; } + .bi-snow3::before { content: "\f56f"; } + .bi-sort-alpha-down-alt::before { content: "\f570"; } + .bi-sort-alpha-down::before { content: "\f571"; } + .bi-sort-alpha-up-alt::before { content: "\f572"; } + .bi-sort-alpha-up::before { content: "\f573"; } + .bi-sort-down-alt::before { content: "\f574"; } + .bi-sort-down::before { content: "\f575"; } + .bi-sort-numeric-down-alt::before { content: "\f576"; } + .bi-sort-numeric-down::before { content: "\f577"; } + .bi-sort-numeric-up-alt::before { content: "\f578"; } + .bi-sort-numeric-up::before { content: "\f579"; } + .bi-sort-up-alt::before { content: "\f57a"; } + .bi-sort-up::before { content: "\f57b"; } + .bi-soundwave::before { content: "\f57c"; } + .bi-speaker-fill::before { content: "\f57d"; } + .bi-speaker::before { content: "\f57e"; } + .bi-speedometer::before { content: "\f57f"; } + .bi-speedometer2::before { content: "\f580"; } + .bi-spellcheck::before { content: "\f581"; } + .bi-square-fill::before { content: "\f582"; } + .bi-square-half::before { content: "\f583"; } + .bi-square::before { content: "\f584"; } + .bi-stack::before { content: "\f585"; } + .bi-star-fill::before { content: "\f586"; } + .bi-star-half::before { content: "\f587"; } + .bi-star::before { content: "\f588"; } + .bi-stars::before { content: "\f589"; } + .bi-stickies-fill::before { content: "\f58a"; } + .bi-stickies::before { content: "\f58b"; } + .bi-sticky-fill::before { content: "\f58c"; } + .bi-sticky::before { content: "\f58d"; } + .bi-stop-btn-fill::before { content: "\f58e"; } + .bi-stop-btn::before { content: "\f58f"; } + .bi-stop-circle-fill::before { content: "\f590"; } + .bi-stop-circle::before { content: "\f591"; } + .bi-stop-fill::before { content: "\f592"; } + .bi-stop::before { content: "\f593"; } + .bi-stoplights-fill::before { content: "\f594"; } + .bi-stoplights::before { content: "\f595"; } + .bi-stopwatch-fill::before { content: "\f596"; } + .bi-stopwatch::before { content: "\f597"; } + .bi-subtract::before { content: "\f598"; } + .bi-suit-club-fill::before { content: "\f599"; } + .bi-suit-club::before { content: "\f59a"; } + .bi-suit-diamond-fill::before { content: "\f59b"; } + .bi-suit-diamond::before { content: "\f59c"; } + .bi-suit-heart-fill::before { content: "\f59d"; } + .bi-suit-heart::before { content: "\f59e"; } + .bi-suit-spade-fill::before { content: "\f59f"; } + .bi-suit-spade::before { content: "\f5a0"; } + .bi-sun-fill::before { content: "\f5a1"; } + .bi-sun::before { content: "\f5a2"; } + .bi-sunglasses::before { content: "\f5a3"; } + .bi-sunrise-fill::before { content: "\f5a4"; } + .bi-sunrise::before { content: "\f5a5"; } + .bi-sunset-fill::before { content: "\f5a6"; } + .bi-sunset::before { content: "\f5a7"; } + .bi-symmetry-horizontal::before { content: "\f5a8"; } + .bi-symmetry-vertical::before { content: "\f5a9"; } + .bi-table::before { content: "\f5aa"; } + .bi-tablet-fill::before { content: "\f5ab"; } + .bi-tablet-landscape-fill::before { content: "\f5ac"; } + .bi-tablet-landscape::before { content: "\f5ad"; } + .bi-tablet::before { content: "\f5ae"; } + .bi-tag-fill::before { content: "\f5af"; } + .bi-tag::before { content: "\f5b0"; } + .bi-tags-fill::before { content: "\f5b1"; } + .bi-tags::before { content: "\f5b2"; } + .bi-telegram::before { content: "\f5b3"; } + .bi-telephone-fill::before { content: "\f5b4"; } + .bi-telephone-forward-fill::before { content: "\f5b5"; } + .bi-telephone-forward::before { content: "\f5b6"; } + .bi-telephone-inbound-fill::before { content: "\f5b7"; } + .bi-telephone-inbound::before { content: "\f5b8"; } + .bi-telephone-minus-fill::before { content: "\f5b9"; } + .bi-telephone-minus::before { content: "\f5ba"; } + .bi-telephone-outbound-fill::before { content: "\f5bb"; } + .bi-telephone-outbound::before { content: "\f5bc"; } + .bi-telephone-plus-fill::before { content: "\f5bd"; } + .bi-telephone-plus::before { content: "\f5be"; } + .bi-telephone-x-fill::before { content: "\f5bf"; } + .bi-telephone-x::before { content: "\f5c0"; } + .bi-telephone::before { content: "\f5c1"; } + .bi-terminal-fill::before { content: "\f5c2"; } + .bi-terminal::before { content: "\f5c3"; } + .bi-text-center::before { content: "\f5c4"; } + .bi-text-indent-left::before { content: "\f5c5"; } + .bi-text-indent-right::before { content: "\f5c6"; } + .bi-text-left::before { content: "\f5c7"; } + .bi-text-paragraph::before { content: "\f5c8"; } + .bi-text-right::before { content: "\f5c9"; } + .bi-textarea-resize::before { content: "\f5ca"; } + .bi-textarea-t::before { content: "\f5cb"; } + .bi-textarea::before { content: "\f5cc"; } + .bi-thermometer-half::before { content: "\f5cd"; } + .bi-thermometer-high::before { content: "\f5ce"; } + .bi-thermometer-low::before { content: "\f5cf"; } + .bi-thermometer-snow::before { content: "\f5d0"; } + .bi-thermometer-sun::before { content: "\f5d1"; } + .bi-thermometer::before { content: "\f5d2"; } + .bi-three-dots-vertical::before { content: "\f5d3"; } + .bi-three-dots::before { content: "\f5d4"; } + .bi-toggle-off::before { content: "\f5d5"; } + .bi-toggle-on::before { content: "\f5d6"; } + .bi-toggle2-off::before { content: "\f5d7"; } + .bi-toggle2-on::before { content: "\f5d8"; } + .bi-toggles::before { content: "\f5d9"; } + .bi-toggles2::before { content: "\f5da"; } + .bi-tools::before { content: "\f5db"; } + .bi-tornado::before { content: "\f5dc"; } + .bi-trash-fill::before { content: "\f5dd"; } + .bi-trash::before { content: "\f5de"; } + .bi-trash2-fill::before { content: "\f5df"; } + .bi-trash2::before { content: "\f5e0"; } + .bi-tree-fill::before { content: "\f5e1"; } + .bi-tree::before { content: "\f5e2"; } + .bi-triangle-fill::before { content: "\f5e3"; } + .bi-triangle-half::before { content: "\f5e4"; } + .bi-triangle::before { content: "\f5e5"; } + .bi-trophy-fill::before { content: "\f5e6"; } + .bi-trophy::before { content: "\f5e7"; } + .bi-tropical-storm::before { content: "\f5e8"; } + .bi-truck-flatbed::before { content: "\f5e9"; } + .bi-truck::before { content: "\f5ea"; } + .bi-tsunami::before { content: "\f5eb"; } + .bi-tv-fill::before { content: "\f5ec"; } + .bi-tv::before { content: "\f5ed"; } + .bi-twitch::before { content: "\f5ee"; } + .bi-twitter::before { content: "\f5ef"; } + .bi-type-bold::before { content: "\f5f0"; } + .bi-type-h1::before { content: "\f5f1"; } + .bi-type-h2::before { content: "\f5f2"; } + .bi-type-h3::before { content: "\f5f3"; } + .bi-type-italic::before { content: "\f5f4"; } + .bi-type-strikethrough::before { content: "\f5f5"; } + .bi-type-underline::before { content: "\f5f6"; } + .bi-type::before { content: "\f5f7"; } + .bi-ui-checks-grid::before { content: "\f5f8"; } + .bi-ui-checks::before { content: "\f5f9"; } + .bi-ui-radios-grid::before { content: "\f5fa"; } + .bi-ui-radios::before { content: "\f5fb"; } + .bi-umbrella-fill::before { content: "\f5fc"; } + .bi-umbrella::before { content: "\f5fd"; } + .bi-union::before { content: "\f5fe"; } + .bi-unlock-fill::before { content: "\f5ff"; } + .bi-unlock::before { content: "\f600"; } + .bi-upc-scan::before { content: "\f601"; } + .bi-upc::before { content: "\f602"; } + .bi-upload::before { content: "\f603"; } + .bi-vector-pen::before { content: "\f604"; } + .bi-view-list::before { content: "\f605"; } + .bi-view-stacked::before { content: "\f606"; } + .bi-vinyl-fill::before { content: "\f607"; } + .bi-vinyl::before { content: "\f608"; } + .bi-voicemail::before { content: "\f609"; } + .bi-volume-down-fill::before { content: "\f60a"; } + .bi-volume-down::before { content: "\f60b"; } + .bi-volume-mute-fill::before { content: "\f60c"; } + .bi-volume-mute::before { content: "\f60d"; } + .bi-volume-off-fill::before { content: "\f60e"; } + .bi-volume-off::before { content: "\f60f"; } + .bi-volume-up-fill::before { content: "\f610"; } + .bi-volume-up::before { content: "\f611"; } + .bi-vr::before { content: "\f612"; } + .bi-wallet-fill::before { content: "\f613"; } + .bi-wallet::before { content: "\f614"; } + .bi-wallet2::before { content: "\f615"; } + .bi-watch::before { content: "\f616"; } + .bi-water::before { content: "\f617"; } + .bi-whatsapp::before { content: "\f618"; } + .bi-wifi-1::before { content: "\f619"; } + .bi-wifi-2::before { content: "\f61a"; } + .bi-wifi-off::before { content: "\f61b"; } + .bi-wifi::before { content: "\f61c"; } + .bi-wind::before { content: "\f61d"; } + .bi-window-dock::before { content: "\f61e"; } + .bi-window-sidebar::before { content: "\f61f"; } + .bi-window::before { content: "\f620"; } + .bi-wrench::before { content: "\f621"; } + .bi-x-circle-fill::before { content: "\f622"; } + .bi-x-circle::before { content: "\f623"; } + .bi-x-diamond-fill::before { content: "\f624"; } + .bi-x-diamond::before { content: "\f625"; } + .bi-x-octagon-fill::before { content: "\f626"; } + .bi-x-octagon::before { content: "\f627"; } + .bi-x-square-fill::before { content: "\f628"; } + .bi-x-square::before { content: "\f629"; } + .bi-x::before { content: "\f62a"; } + .bi-youtube::before { content: "\f62b"; } + .bi-zoom-in::before { content: "\f62c"; } + .bi-zoom-out::before { content: "\f62d"; } + .bi-bank::before { content: "\f62e"; } + .bi-bank2::before { content: "\f62f"; } + .bi-bell-slash-fill::before { content: "\f630"; } + .bi-bell-slash::before { content: "\f631"; } + .bi-cash-coin::before { content: "\f632"; } + .bi-check-lg::before { content: "\f633"; } + .bi-coin::before { content: "\f634"; } + .bi-currency-bitcoin::before { content: "\f635"; } + .bi-currency-dollar::before { content: "\f636"; } + .bi-currency-euro::before { content: "\f637"; } + .bi-currency-exchange::before { content: "\f638"; } + .bi-currency-pound::before { content: "\f639"; } + .bi-currency-yen::before { content: "\f63a"; } + .bi-dash-lg::before { content: "\f63b"; } + .bi-exclamation-lg::before { content: "\f63c"; } + .bi-file-earmark-pdf-fill::before { content: "\f63d"; } + .bi-file-earmark-pdf::before { content: "\f63e"; } + .bi-file-pdf-fill::before { content: "\f63f"; } + .bi-file-pdf::before { content: "\f640"; } + .bi-gender-ambiguous::before { content: "\f641"; } + .bi-gender-female::before { content: "\f642"; } + .bi-gender-male::before { content: "\f643"; } + .bi-gender-trans::before { content: "\f644"; } + .bi-headset-vr::before { content: "\f645"; } + .bi-info-lg::before { content: "\f646"; } + .bi-mastodon::before { content: "\f647"; } + .bi-messenger::before { content: "\f648"; } + .bi-piggy-bank-fill::before { content: "\f649"; } + .bi-piggy-bank::before { content: "\f64a"; } + .bi-pin-map-fill::before { content: "\f64b"; } + .bi-pin-map::before { content: "\f64c"; } + .bi-plus-lg::before { content: "\f64d"; } + .bi-question-lg::before { content: "\f64e"; } + .bi-recycle::before { content: "\f64f"; } + .bi-reddit::before { content: "\f650"; } + .bi-safe-fill::before { content: "\f651"; } + .bi-safe2-fill::before { content: "\f652"; } + .bi-safe2::before { content: "\f653"; } + .bi-sd-card-fill::before { content: "\f654"; } + .bi-sd-card::before { content: "\f655"; } + .bi-skype::before { content: "\f656"; } + .bi-slash-lg::before { content: "\f657"; } + .bi-translate::before { content: "\f658"; } + .bi-x-lg::before { content: "\f659"; } + .bi-safe::before { content: "\f65a"; } + .bi-apple::before { content: "\f65b"; } + .bi-microsoft::before { content: "\f65d"; } + .bi-windows::before { content: "\f65e"; } + .bi-behance::before { content: "\f65c"; } + .bi-dribbble::before { content: "\f65f"; } + .bi-line::before { content: "\f660"; } + .bi-medium::before { content: "\f661"; } + .bi-paypal::before { content: "\f662"; } + .bi-pinterest::before { content: "\f663"; } + .bi-signal::before { content: "\f664"; } + .bi-snapchat::before { content: "\f665"; } + .bi-spotify::before { content: "\f666"; } + .bi-stack-overflow::before { content: "\f667"; } + .bi-strava::before { content: "\f668"; } + .bi-wordpress::before { content: "\f669"; } + .bi-vimeo::before { content: "\f66a"; } + .bi-activity::before { content: "\f66b"; } + .bi-easel2-fill::before { content: "\f66c"; } + .bi-easel2::before { content: "\f66d"; } + .bi-easel3-fill::before { content: "\f66e"; } + .bi-easel3::before { content: "\f66f"; } + .bi-fan::before { content: "\f670"; } + .bi-fingerprint::before { content: "\f671"; } + .bi-graph-down-arrow::before { content: "\f672"; } + .bi-graph-up-arrow::before { content: "\f673"; } + .bi-hypnotize::before { content: "\f674"; } + .bi-magic::before { content: "\f675"; } + .bi-person-rolodex::before { content: "\f676"; } + .bi-person-video::before { content: "\f677"; } + .bi-person-video2::before { content: "\f678"; } + .bi-person-video3::before { content: "\f679"; } + .bi-person-workspace::before { content: "\f67a"; } + .bi-radioactive::before { content: "\f67b"; } + .bi-webcam-fill::before { content: "\f67c"; } + .bi-webcam::before { content: "\f67d"; } + .bi-yin-yang::before { content: "\f67e"; } + .bi-bandaid-fill::before { content: "\f680"; } + .bi-bandaid::before { content: "\f681"; } + .bi-bluetooth::before { content: "\f682"; } + .bi-body-text::before { content: "\f683"; } + .bi-boombox::before { content: "\f684"; } + .bi-boxes::before { content: "\f685"; } + .bi-dpad-fill::before { content: "\f686"; } + .bi-dpad::before { content: "\f687"; } + .bi-ear-fill::before { content: "\f688"; } + .bi-ear::before { content: "\f689"; } + .bi-envelope-check-fill::before { content: "\f68b"; } + .bi-envelope-check::before { content: "\f68c"; } + .bi-envelope-dash-fill::before { content: "\f68e"; } + .bi-envelope-dash::before { content: "\f68f"; } + .bi-envelope-exclamation-fill::before { content: "\f691"; } + .bi-envelope-exclamation::before { content: "\f692"; } + .bi-envelope-plus-fill::before { content: "\f693"; } + .bi-envelope-plus::before { content: "\f694"; } + .bi-envelope-slash-fill::before { content: "\f696"; } + .bi-envelope-slash::before { content: "\f697"; } + .bi-envelope-x-fill::before { content: "\f699"; } + .bi-envelope-x::before { content: "\f69a"; } + .bi-explicit-fill::before { content: "\f69b"; } + .bi-explicit::before { content: "\f69c"; } + .bi-git::before { content: "\f69d"; } + .bi-infinity::before { content: "\f69e"; } + .bi-list-columns-reverse::before { content: "\f69f"; } + .bi-list-columns::before { content: "\f6a0"; } + .bi-meta::before { content: "\f6a1"; } + .bi-nintendo-switch::before { content: "\f6a4"; } + .bi-pc-display-horizontal::before { content: "\f6a5"; } + .bi-pc-display::before { content: "\f6a6"; } + .bi-pc-horizontal::before { content: "\f6a7"; } + .bi-pc::before { content: "\f6a8"; } + .bi-playstation::before { content: "\f6a9"; } + .bi-plus-slash-minus::before { content: "\f6aa"; } + .bi-projector-fill::before { content: "\f6ab"; } + .bi-projector::before { content: "\f6ac"; } + .bi-qr-code-scan::before { content: "\f6ad"; } + .bi-qr-code::before { content: "\f6ae"; } + .bi-quora::before { content: "\f6af"; } + .bi-quote::before { content: "\f6b0"; } + .bi-robot::before { content: "\f6b1"; } + .bi-send-check-fill::before { content: "\f6b2"; } + .bi-send-check::before { content: "\f6b3"; } + .bi-send-dash-fill::before { content: "\f6b4"; } + .bi-send-dash::before { content: "\f6b5"; } + .bi-send-exclamation-fill::before { content: "\f6b7"; } + .bi-send-exclamation::before { content: "\f6b8"; } + .bi-send-fill::before { content: "\f6b9"; } + .bi-send-plus-fill::before { content: "\f6ba"; } + .bi-send-plus::before { content: "\f6bb"; } + .bi-send-slash-fill::before { content: "\f6bc"; } + .bi-send-slash::before { content: "\f6bd"; } + .bi-send-x-fill::before { content: "\f6be"; } + .bi-send-x::before { content: "\f6bf"; } + .bi-send::before { content: "\f6c0"; } + .bi-steam::before { content: "\f6c1"; } + .bi-terminal-dash::before { content: "\f6c3"; } + .bi-terminal-plus::before { content: "\f6c4"; } + .bi-terminal-split::before { content: "\f6c5"; } + .bi-ticket-detailed-fill::before { content: "\f6c6"; } + .bi-ticket-detailed::before { content: "\f6c7"; } + .bi-ticket-fill::before { content: "\f6c8"; } + .bi-ticket-perforated-fill::before { content: "\f6c9"; } + .bi-ticket-perforated::before { content: "\f6ca"; } + .bi-ticket::before { content: "\f6cb"; } + .bi-tiktok::before { content: "\f6cc"; } + .bi-window-dash::before { content: "\f6cd"; } + .bi-window-desktop::before { content: "\f6ce"; } + .bi-window-fullscreen::before { content: "\f6cf"; } + .bi-window-plus::before { content: "\f6d0"; } + .bi-window-split::before { content: "\f6d1"; } + .bi-window-stack::before { content: "\f6d2"; } + .bi-window-x::before { content: "\f6d3"; } + .bi-xbox::before { content: "\f6d4"; } + .bi-ethernet::before { content: "\f6d5"; } + .bi-hdmi-fill::before { content: "\f6d6"; } + .bi-hdmi::before { content: "\f6d7"; } + .bi-usb-c-fill::before { content: "\f6d8"; } + .bi-usb-c::before { content: "\f6d9"; } + .bi-usb-fill::before { content: "\f6da"; } + .bi-usb-plug-fill::before { content: "\f6db"; } + .bi-usb-plug::before { content: "\f6dc"; } + .bi-usb-symbol::before { content: "\f6dd"; } + .bi-usb::before { content: "\f6de"; } + .bi-boombox-fill::before { content: "\f6df"; } + .bi-displayport::before { content: "\f6e1"; } + .bi-gpu-card::before { content: "\f6e2"; } + .bi-memory::before { content: "\f6e3"; } + .bi-modem-fill::before { content: "\f6e4"; } + .bi-modem::before { content: "\f6e5"; } + .bi-motherboard-fill::before { content: "\f6e6"; } + .bi-motherboard::before { content: "\f6e7"; } + .bi-optical-audio-fill::before { content: "\f6e8"; } + .bi-optical-audio::before { content: "\f6e9"; } + .bi-pci-card::before { content: "\f6ea"; } + .bi-router-fill::before { content: "\f6eb"; } + .bi-router::before { content: "\f6ec"; } + .bi-thunderbolt-fill::before { content: "\f6ef"; } + .bi-thunderbolt::before { content: "\f6f0"; } + .bi-usb-drive-fill::before { content: "\f6f1"; } + .bi-usb-drive::before { content: "\f6f2"; } + .bi-usb-micro-fill::before { content: "\f6f3"; } + .bi-usb-micro::before { content: "\f6f4"; } + .bi-usb-mini-fill::before { content: "\f6f5"; } + .bi-usb-mini::before { content: "\f6f6"; } + .bi-cloud-haze2::before { content: "\f6f7"; } + .bi-device-hdd-fill::before { content: "\f6f8"; } + .bi-device-hdd::before { content: "\f6f9"; } + .bi-device-ssd-fill::before { content: "\f6fa"; } + .bi-device-ssd::before { content: "\f6fb"; } + .bi-displayport-fill::before { content: "\f6fc"; } + .bi-mortarboard-fill::before { content: "\f6fd"; } + .bi-mortarboard::before { content: "\f6fe"; } + .bi-terminal-x::before { content: "\f6ff"; } + .bi-arrow-through-heart-fill::before { content: "\f700"; } + .bi-arrow-through-heart::before { content: "\f701"; } + .bi-badge-sd-fill::before { content: "\f702"; } + .bi-badge-sd::before { content: "\f703"; } + .bi-bag-heart-fill::before { content: "\f704"; } + .bi-bag-heart::before { content: "\f705"; } + .bi-balloon-fill::before { content: "\f706"; } + .bi-balloon-heart-fill::before { content: "\f707"; } + .bi-balloon-heart::before { content: "\f708"; } + .bi-balloon::before { content: "\f709"; } + .bi-box2-fill::before { content: "\f70a"; } + .bi-box2-heart-fill::before { content: "\f70b"; } + .bi-box2-heart::before { content: "\f70c"; } + .bi-box2::before { content: "\f70d"; } + .bi-braces-asterisk::before { content: "\f70e"; } + .bi-calendar-heart-fill::before { content: "\f70f"; } + .bi-calendar-heart::before { content: "\f710"; } + .bi-calendar2-heart-fill::before { content: "\f711"; } + .bi-calendar2-heart::before { content: "\f712"; } + .bi-chat-heart-fill::before { content: "\f713"; } + .bi-chat-heart::before { content: "\f714"; } + .bi-chat-left-heart-fill::before { content: "\f715"; } + .bi-chat-left-heart::before { content: "\f716"; } + .bi-chat-right-heart-fill::before { content: "\f717"; } + .bi-chat-right-heart::before { content: "\f718"; } + .bi-chat-square-heart-fill::before { content: "\f719"; } + .bi-chat-square-heart::before { content: "\f71a"; } + .bi-clipboard-check-fill::before { content: "\f71b"; } + .bi-clipboard-data-fill::before { content: "\f71c"; } + .bi-clipboard-fill::before { content: "\f71d"; } + .bi-clipboard-heart-fill::before { content: "\f71e"; } + .bi-clipboard-heart::before { content: "\f71f"; } + .bi-clipboard-minus-fill::before { content: "\f720"; } + .bi-clipboard-plus-fill::before { content: "\f721"; } + .bi-clipboard-pulse::before { content: "\f722"; } + .bi-clipboard-x-fill::before { content: "\f723"; } + .bi-clipboard2-check-fill::before { content: "\f724"; } + .bi-clipboard2-check::before { content: "\f725"; } + .bi-clipboard2-data-fill::before { content: "\f726"; } + .bi-clipboard2-data::before { content: "\f727"; } + .bi-clipboard2-fill::before { content: "\f728"; } + .bi-clipboard2-heart-fill::before { content: "\f729"; } + .bi-clipboard2-heart::before { content: "\f72a"; } + .bi-clipboard2-minus-fill::before { content: "\f72b"; } + .bi-clipboard2-minus::before { content: "\f72c"; } + .bi-clipboard2-plus-fill::before { content: "\f72d"; } + .bi-clipboard2-plus::before { content: "\f72e"; } + .bi-clipboard2-pulse-fill::before { content: "\f72f"; } + .bi-clipboard2-pulse::before { content: "\f730"; } + .bi-clipboard2-x-fill::before { content: "\f731"; } + .bi-clipboard2-x::before { content: "\f732"; } + .bi-clipboard2::before { content: "\f733"; } + .bi-emoji-kiss-fill::before { content: "\f734"; } + .bi-emoji-kiss::before { content: "\f735"; } + .bi-envelope-heart-fill::before { content: "\f736"; } + .bi-envelope-heart::before { content: "\f737"; } + .bi-envelope-open-heart-fill::before { content: "\f738"; } + .bi-envelope-open-heart::before { content: "\f739"; } + .bi-envelope-paper-fill::before { content: "\f73a"; } + .bi-envelope-paper-heart-fill::before { content: "\f73b"; } + .bi-envelope-paper-heart::before { content: "\f73c"; } + .bi-envelope-paper::before { content: "\f73d"; } + .bi-filetype-aac::before { content: "\f73e"; } + .bi-filetype-ai::before { content: "\f73f"; } + .bi-filetype-bmp::before { content: "\f740"; } + .bi-filetype-cs::before { content: "\f741"; } + .bi-filetype-css::before { content: "\f742"; } + .bi-filetype-csv::before { content: "\f743"; } + .bi-filetype-doc::before { content: "\f744"; } + .bi-filetype-docx::before { content: "\f745"; } + .bi-filetype-exe::before { content: "\f746"; } + .bi-filetype-gif::before { content: "\f747"; } + .bi-filetype-heic::before { content: "\f748"; } + .bi-filetype-html::before { content: "\f749"; } + .bi-filetype-java::before { content: "\f74a"; } + .bi-filetype-jpg::before { content: "\f74b"; } + .bi-filetype-js::before { content: "\f74c"; } + .bi-filetype-jsx::before { content: "\f74d"; } + .bi-filetype-key::before { content: "\f74e"; } + .bi-filetype-m4p::before { content: "\f74f"; } + .bi-filetype-md::before { content: "\f750"; } + .bi-filetype-mdx::before { content: "\f751"; } + .bi-filetype-mov::before { content: "\f752"; } + .bi-filetype-mp3::before { content: "\f753"; } + .bi-filetype-mp4::before { content: "\f754"; } + .bi-filetype-otf::before { content: "\f755"; } + .bi-filetype-pdf::before { content: "\f756"; } + .bi-filetype-php::before { content: "\f757"; } + .bi-filetype-png::before { content: "\f758"; } + .bi-filetype-ppt::before { content: "\f75a"; } + .bi-filetype-psd::before { content: "\f75b"; } + .bi-filetype-py::before { content: "\f75c"; } + .bi-filetype-raw::before { content: "\f75d"; } + .bi-filetype-rb::before { content: "\f75e"; } + .bi-filetype-sass::before { content: "\f75f"; } + .bi-filetype-scss::before { content: "\f760"; } + .bi-filetype-sh::before { content: "\f761"; } + .bi-filetype-svg::before { content: "\f762"; } + .bi-filetype-tiff::before { content: "\f763"; } + .bi-filetype-tsx::before { content: "\f764"; } + .bi-filetype-ttf::before { content: "\f765"; } + .bi-filetype-txt::before { content: "\f766"; } + .bi-filetype-wav::before { content: "\f767"; } + .bi-filetype-woff::before { content: "\f768"; } + .bi-filetype-xls::before { content: "\f76a"; } + .bi-filetype-xml::before { content: "\f76b"; } + .bi-filetype-yml::before { content: "\f76c"; } + .bi-heart-arrow::before { content: "\f76d"; } + .bi-heart-pulse-fill::before { content: "\f76e"; } + .bi-heart-pulse::before { content: "\f76f"; } + .bi-heartbreak-fill::before { content: "\f770"; } + .bi-heartbreak::before { content: "\f771"; } + .bi-hearts::before { content: "\f772"; } + .bi-hospital-fill::before { content: "\f773"; } + .bi-hospital::before { content: "\f774"; } + .bi-house-heart-fill::before { content: "\f775"; } + .bi-house-heart::before { content: "\f776"; } + .bi-incognito::before { content: "\f777"; } + .bi-magnet-fill::before { content: "\f778"; } + .bi-magnet::before { content: "\f779"; } + .bi-person-heart::before { content: "\f77a"; } + .bi-person-hearts::before { content: "\f77b"; } + .bi-phone-flip::before { content: "\f77c"; } + .bi-plugin::before { content: "\f77d"; } + .bi-postage-fill::before { content: "\f77e"; } + .bi-postage-heart-fill::before { content: "\f77f"; } + .bi-postage-heart::before { content: "\f780"; } + .bi-postage::before { content: "\f781"; } + .bi-postcard-fill::before { content: "\f782"; } + .bi-postcard-heart-fill::before { content: "\f783"; } + .bi-postcard-heart::before { content: "\f784"; } + .bi-postcard::before { content: "\f785"; } + .bi-search-heart-fill::before { content: "\f786"; } + .bi-search-heart::before { content: "\f787"; } + .bi-sliders2-vertical::before { content: "\f788"; } + .bi-sliders2::before { content: "\f789"; } + .bi-trash3-fill::before { content: "\f78a"; } + .bi-trash3::before { content: "\f78b"; } + .bi-valentine::before { content: "\f78c"; } + .bi-valentine2::before { content: "\f78d"; } + .bi-wrench-adjustable-circle-fill::before { content: "\f78e"; } + .bi-wrench-adjustable-circle::before { content: "\f78f"; } + .bi-wrench-adjustable::before { content: "\f790"; } + .bi-filetype-json::before { content: "\f791"; } + .bi-filetype-pptx::before { content: "\f792"; } + .bi-filetype-xlsx::before { content: "\f793"; } + .bi-1-circle-fill::before { content: "\f796"; } + .bi-1-circle::before { content: "\f797"; } + .bi-1-square-fill::before { content: "\f798"; } + .bi-1-square::before { content: "\f799"; } + .bi-2-circle-fill::before { content: "\f79c"; } + .bi-2-circle::before { content: "\f79d"; } + .bi-2-square-fill::before { content: "\f79e"; } + .bi-2-square::before { content: "\f79f"; } + .bi-3-circle-fill::before { content: "\f7a2"; } + .bi-3-circle::before { content: "\f7a3"; } + .bi-3-square-fill::before { content: "\f7a4"; } + .bi-3-square::before { content: "\f7a5"; } + .bi-4-circle-fill::before { content: "\f7a8"; } + .bi-4-circle::before { content: "\f7a9"; } + .bi-4-square-fill::before { content: "\f7aa"; } + .bi-4-square::before { content: "\f7ab"; } + .bi-5-circle-fill::before { content: "\f7ae"; } + .bi-5-circle::before { content: "\f7af"; } + .bi-5-square-fill::before { content: "\f7b0"; } + .bi-5-square::before { content: "\f7b1"; } + .bi-6-circle-fill::before { content: "\f7b4"; } + .bi-6-circle::before { content: "\f7b5"; } + .bi-6-square-fill::before { content: "\f7b6"; } + .bi-6-square::before { content: "\f7b7"; } + .bi-7-circle-fill::before { content: "\f7ba"; } + .bi-7-circle::before { content: "\f7bb"; } + .bi-7-square-fill::before { content: "\f7bc"; } + .bi-7-square::before { content: "\f7bd"; } + .bi-8-circle-fill::before { content: "\f7c0"; } + .bi-8-circle::before { content: "\f7c1"; } + .bi-8-square-fill::before { content: "\f7c2"; } + .bi-8-square::before { content: "\f7c3"; } + .bi-9-circle-fill::before { content: "\f7c6"; } + .bi-9-circle::before { content: "\f7c7"; } + .bi-9-square-fill::before { content: "\f7c8"; } + .bi-9-square::before { content: "\f7c9"; } + .bi-airplane-engines-fill::before { content: "\f7ca"; } + .bi-airplane-engines::before { content: "\f7cb"; } + .bi-airplane-fill::before { content: "\f7cc"; } + .bi-airplane::before { content: "\f7cd"; } + .bi-alexa::before { content: "\f7ce"; } + .bi-alipay::before { content: "\f7cf"; } + .bi-android::before { content: "\f7d0"; } + .bi-android2::before { content: "\f7d1"; } + .bi-box-fill::before { content: "\f7d2"; } + .bi-box-seam-fill::before { content: "\f7d3"; } + .bi-browser-chrome::before { content: "\f7d4"; } + .bi-browser-edge::before { content: "\f7d5"; } + .bi-browser-firefox::before { content: "\f7d6"; } + .bi-browser-safari::before { content: "\f7d7"; } + .bi-c-circle-fill::before { content: "\f7da"; } + .bi-c-circle::before { content: "\f7db"; } + .bi-c-square-fill::before { content: "\f7dc"; } + .bi-c-square::before { content: "\f7dd"; } + .bi-capsule-pill::before { content: "\f7de"; } + .bi-capsule::before { content: "\f7df"; } + .bi-car-front-fill::before { content: "\f7e0"; } + .bi-car-front::before { content: "\f7e1"; } + .bi-cassette-fill::before { content: "\f7e2"; } + .bi-cassette::before { content: "\f7e3"; } + .bi-cc-circle-fill::before { content: "\f7e6"; } + .bi-cc-circle::before { content: "\f7e7"; } + .bi-cc-square-fill::before { content: "\f7e8"; } + .bi-cc-square::before { content: "\f7e9"; } + .bi-cup-hot-fill::before { content: "\f7ea"; } + .bi-cup-hot::before { content: "\f7eb"; } + .bi-currency-rupee::before { content: "\f7ec"; } + .bi-dropbox::before { content: "\f7ed"; } + .bi-escape::before { content: "\f7ee"; } + .bi-fast-forward-btn-fill::before { content: "\f7ef"; } + .bi-fast-forward-btn::before { content: "\f7f0"; } + .bi-fast-forward-circle-fill::before { content: "\f7f1"; } + .bi-fast-forward-circle::before { content: "\f7f2"; } + .bi-fast-forward-fill::before { content: "\f7f3"; } + .bi-fast-forward::before { content: "\f7f4"; } + .bi-filetype-sql::before { content: "\f7f5"; } + .bi-fire::before { content: "\f7f6"; } + .bi-google-play::before { content: "\f7f7"; } + .bi-h-circle-fill::before { content: "\f7fa"; } + .bi-h-circle::before { content: "\f7fb"; } + .bi-h-square-fill::before { content: "\f7fc"; } + .bi-h-square::before { content: "\f7fd"; } + .bi-indent::before { content: "\f7fe"; } + .bi-lungs-fill::before { content: "\f7ff"; } + .bi-lungs::before { content: "\f800"; } + .bi-microsoft-teams::before { content: "\f801"; } + .bi-p-circle-fill::before { content: "\f804"; } + .bi-p-circle::before { content: "\f805"; } + .bi-p-square-fill::before { content: "\f806"; } + .bi-p-square::before { content: "\f807"; } + .bi-pass-fill::before { content: "\f808"; } + .bi-pass::before { content: "\f809"; } + .bi-prescription::before { content: "\f80a"; } + .bi-prescription2::before { content: "\f80b"; } + .bi-r-circle-fill::before { content: "\f80e"; } + .bi-r-circle::before { content: "\f80f"; } + .bi-r-square-fill::before { content: "\f810"; } + .bi-r-square::before { content: "\f811"; } + .bi-repeat-1::before { content: "\f812"; } + .bi-repeat::before { content: "\f813"; } + .bi-rewind-btn-fill::before { content: "\f814"; } + .bi-rewind-btn::before { content: "\f815"; } + .bi-rewind-circle-fill::before { content: "\f816"; } + .bi-rewind-circle::before { content: "\f817"; } + .bi-rewind-fill::before { content: "\f818"; } + .bi-rewind::before { content: "\f819"; } + .bi-train-freight-front-fill::before { content: "\f81a"; } + .bi-train-freight-front::before { content: "\f81b"; } + .bi-train-front-fill::before { content: "\f81c"; } + .bi-train-front::before { content: "\f81d"; } + .bi-train-lightrail-front-fill::before { content: "\f81e"; } + .bi-train-lightrail-front::before { content: "\f81f"; } + .bi-truck-front-fill::before { content: "\f820"; } + .bi-truck-front::before { content: "\f821"; } + .bi-ubuntu::before { content: "\f822"; } + .bi-unindent::before { content: "\f823"; } + .bi-unity::before { content: "\f824"; } + .bi-universal-access-circle::before { content: "\f825"; } + .bi-universal-access::before { content: "\f826"; } + .bi-virus::before { content: "\f827"; } + .bi-virus2::before { content: "\f828"; } + .bi-wechat::before { content: "\f829"; } + .bi-yelp::before { content: "\f82a"; } + .bi-sign-stop-fill::before { content: "\f82b"; } + .bi-sign-stop-lights-fill::before { content: "\f82c"; } + .bi-sign-stop-lights::before { content: "\f82d"; } + .bi-sign-stop::before { content: "\f82e"; } + .bi-sign-turn-left-fill::before { content: "\f82f"; } + .bi-sign-turn-left::before { content: "\f830"; } + .bi-sign-turn-right-fill::before { content: "\f831"; } + .bi-sign-turn-right::before { content: "\f832"; } + .bi-sign-turn-slight-left-fill::before { content: "\f833"; } + .bi-sign-turn-slight-left::before { content: "\f834"; } + .bi-sign-turn-slight-right-fill::before { content: "\f835"; } + .bi-sign-turn-slight-right::before { content: "\f836"; } + .bi-sign-yield-fill::before { content: "\f837"; } + .bi-sign-yield::before { content: "\f838"; } + .bi-ev-station-fill::before { content: "\f839"; } + .bi-ev-station::before { content: "\f83a"; } + .bi-fuel-pump-diesel-fill::before { content: "\f83b"; } + .bi-fuel-pump-diesel::before { content: "\f83c"; } + .bi-fuel-pump-fill::before { content: "\f83d"; } + .bi-fuel-pump::before { content: "\f83e"; } + .bi-0-circle-fill::before { content: "\f83f"; } + .bi-0-circle::before { content: "\f840"; } + .bi-0-square-fill::before { content: "\f841"; } + .bi-0-square::before { content: "\f842"; } + .bi-rocket-fill::before { content: "\f843"; } + .bi-rocket-takeoff-fill::before { content: "\f844"; } + .bi-rocket-takeoff::before { content: "\f845"; } + .bi-rocket::before { content: "\f846"; } + .bi-stripe::before { content: "\f847"; } + .bi-subscript::before { content: "\f848"; } + .bi-superscript::before { content: "\f849"; } + .bi-trello::before { content: "\f84a"; } + .bi-envelope-at-fill::before { content: "\f84b"; } + .bi-envelope-at::before { content: "\f84c"; } + .bi-regex::before { content: "\f84d"; } + .bi-text-wrap::before { content: "\f84e"; } + .bi-sign-dead-end-fill::before { content: "\f84f"; } + .bi-sign-dead-end::before { content: "\f850"; } + .bi-sign-do-not-enter-fill::before { content: "\f851"; } + .bi-sign-do-not-enter::before { content: "\f852"; } + .bi-sign-intersection-fill::before { content: "\f853"; } + .bi-sign-intersection-side-fill::before { content: "\f854"; } + .bi-sign-intersection-side::before { content: "\f855"; } + .bi-sign-intersection-t-fill::before { content: "\f856"; } + .bi-sign-intersection-t::before { content: "\f857"; } + .bi-sign-intersection-y-fill::before { content: "\f858"; } + .bi-sign-intersection-y::before { content: "\f859"; } + .bi-sign-intersection::before { content: "\f85a"; } + .bi-sign-merge-left-fill::before { content: "\f85b"; } + .bi-sign-merge-left::before { content: "\f85c"; } + .bi-sign-merge-right-fill::before { content: "\f85d"; } + .bi-sign-merge-right::before { content: "\f85e"; } + .bi-sign-no-left-turn-fill::before { content: "\f85f"; } + .bi-sign-no-left-turn::before { content: "\f860"; } + .bi-sign-no-parking-fill::before { content: "\f861"; } + .bi-sign-no-parking::before { content: "\f862"; } + .bi-sign-no-right-turn-fill::before { content: "\f863"; } + .bi-sign-no-right-turn::before { content: "\f864"; } + .bi-sign-railroad-fill::before { content: "\f865"; } + .bi-sign-railroad::before { content: "\f866"; } + .bi-building-add::before { content: "\f867"; } + .bi-building-check::before { content: "\f868"; } + .bi-building-dash::before { content: "\f869"; } + .bi-building-down::before { content: "\f86a"; } + .bi-building-exclamation::before { content: "\f86b"; } + .bi-building-fill-add::before { content: "\f86c"; } + .bi-building-fill-check::before { content: "\f86d"; } + .bi-building-fill-dash::before { content: "\f86e"; } + .bi-building-fill-down::before { content: "\f86f"; } + .bi-building-fill-exclamation::before { content: "\f870"; } + .bi-building-fill-gear::before { content: "\f871"; } + .bi-building-fill-lock::before { content: "\f872"; } + .bi-building-fill-slash::before { content: "\f873"; } + .bi-building-fill-up::before { content: "\f874"; } + .bi-building-fill-x::before { content: "\f875"; } + .bi-building-fill::before { content: "\f876"; } + .bi-building-gear::before { content: "\f877"; } + .bi-building-lock::before { content: "\f878"; } + .bi-building-slash::before { content: "\f879"; } + .bi-building-up::before { content: "\f87a"; } + .bi-building-x::before { content: "\f87b"; } + .bi-buildings-fill::before { content: "\f87c"; } + .bi-buildings::before { content: "\f87d"; } + .bi-bus-front-fill::before { content: "\f87e"; } + .bi-bus-front::before { content: "\f87f"; } + .bi-ev-front-fill::before { content: "\f880"; } + .bi-ev-front::before { content: "\f881"; } + .bi-globe-americas::before { content: "\f882"; } + .bi-globe-asia-australia::before { content: "\f883"; } + .bi-globe-central-south-asia::before { content: "\f884"; } + .bi-globe-europe-africa::before { content: "\f885"; } + .bi-house-add-fill::before { content: "\f886"; } + .bi-house-add::before { content: "\f887"; } + .bi-house-check-fill::before { content: "\f888"; } + .bi-house-check::before { content: "\f889"; } + .bi-house-dash-fill::before { content: "\f88a"; } + .bi-house-dash::before { content: "\f88b"; } + .bi-house-down-fill::before { content: "\f88c"; } + .bi-house-down::before { content: "\f88d"; } + .bi-house-exclamation-fill::before { content: "\f88e"; } + .bi-house-exclamation::before { content: "\f88f"; } + .bi-house-gear-fill::before { content: "\f890"; } + .bi-house-gear::before { content: "\f891"; } + .bi-house-lock-fill::before { content: "\f892"; } + .bi-house-lock::before { content: "\f893"; } + .bi-house-slash-fill::before { content: "\f894"; } + .bi-house-slash::before { content: "\f895"; } + .bi-house-up-fill::before { content: "\f896"; } + .bi-house-up::before { content: "\f897"; } + .bi-house-x-fill::before { content: "\f898"; } + .bi-house-x::before { content: "\f899"; } + .bi-person-add::before { content: "\f89a"; } + .bi-person-down::before { content: "\f89b"; } + .bi-person-exclamation::before { content: "\f89c"; } + .bi-person-fill-add::before { content: "\f89d"; } + .bi-person-fill-check::before { content: "\f89e"; } + .bi-person-fill-dash::before { content: "\f89f"; } + .bi-person-fill-down::before { content: "\f8a0"; } + .bi-person-fill-exclamation::before { content: "\f8a1"; } + .bi-person-fill-gear::before { content: "\f8a2"; } + .bi-person-fill-lock::before { content: "\f8a3"; } + .bi-person-fill-slash::before { content: "\f8a4"; } + .bi-person-fill-up::before { content: "\f8a5"; } + .bi-person-fill-x::before { content: "\f8a6"; } + .bi-person-gear::before { content: "\f8a7"; } + .bi-person-lock::before { content: "\f8a8"; } + .bi-person-slash::before { content: "\f8a9"; } + .bi-person-up::before { content: "\f8aa"; } + .bi-scooter::before { content: "\f8ab"; } + .bi-taxi-front-fill::before { content: "\f8ac"; } + .bi-taxi-front::before { content: "\f8ad"; } + .bi-amd::before { content: "\f8ae"; } + .bi-database-add::before { content: "\f8af"; } + .bi-database-check::before { content: "\f8b0"; } + .bi-database-dash::before { content: "\f8b1"; } + .bi-database-down::before { content: "\f8b2"; } + .bi-database-exclamation::before { content: "\f8b3"; } + .bi-database-fill-add::before { content: "\f8b4"; } + .bi-database-fill-check::before { content: "\f8b5"; } + .bi-database-fill-dash::before { content: "\f8b6"; } + .bi-database-fill-down::before { content: "\f8b7"; } + .bi-database-fill-exclamation::before { content: "\f8b8"; } + .bi-database-fill-gear::before { content: "\f8b9"; } + .bi-database-fill-lock::before { content: "\f8ba"; } + .bi-database-fill-slash::before { content: "\f8bb"; } + .bi-database-fill-up::before { content: "\f8bc"; } + .bi-database-fill-x::before { content: "\f8bd"; } + .bi-database-fill::before { content: "\f8be"; } + .bi-database-gear::before { content: "\f8bf"; } + .bi-database-lock::before { content: "\f8c0"; } + .bi-database-slash::before { content: "\f8c1"; } + .bi-database-up::before { content: "\f8c2"; } + .bi-database-x::before { content: "\f8c3"; } + .bi-database::before { content: "\f8c4"; } + .bi-houses-fill::before { content: "\f8c5"; } + .bi-houses::before { content: "\f8c6"; } + .bi-nvidia::before { content: "\f8c7"; } + .bi-person-vcard-fill::before { content: "\f8c8"; } + .bi-person-vcard::before { content: "\f8c9"; } + .bi-sina-weibo::before { content: "\f8ca"; } + .bi-tencent-qq::before { content: "\f8cb"; } + .bi-wikipedia::before { content: "\f8cc"; } + .bi-alphabet-uppercase::before { content: "\f2a5"; } + .bi-alphabet::before { content: "\f68a"; } + .bi-amazon::before { content: "\f68d"; } + .bi-arrows-collapse-vertical::before { content: "\f690"; } + .bi-arrows-expand-vertical::before { content: "\f695"; } + .bi-arrows-vertical::before { content: "\f698"; } + .bi-arrows::before { content: "\f6a2"; } + .bi-ban-fill::before { content: "\f6a3"; } + .bi-ban::before { content: "\f6b6"; } + .bi-bing::before { content: "\f6c2"; } + .bi-cake::before { content: "\f6e0"; } + .bi-cake2::before { content: "\f6ed"; } + .bi-cookie::before { content: "\f6ee"; } + .bi-copy::before { content: "\f759"; } + .bi-crosshair::before { content: "\f769"; } + .bi-crosshair2::before { content: "\f794"; } + .bi-emoji-astonished-fill::before { content: "\f795"; } + .bi-emoji-astonished::before { content: "\f79a"; } + .bi-emoji-grimace-fill::before { content: "\f79b"; } + .bi-emoji-grimace::before { content: "\f7a0"; } + .bi-emoji-grin-fill::before { content: "\f7a1"; } + .bi-emoji-grin::before { content: "\f7a6"; } + .bi-emoji-surprise-fill::before { content: "\f7a7"; } + .bi-emoji-surprise::before { content: "\f7ac"; } + .bi-emoji-tear-fill::before { content: "\f7ad"; } + .bi-emoji-tear::before { content: "\f7b2"; } + .bi-envelope-arrow-down-fill::before { content: "\f7b3"; } + .bi-envelope-arrow-down::before { content: "\f7b8"; } + .bi-envelope-arrow-up-fill::before { content: "\f7b9"; } + .bi-envelope-arrow-up::before { content: "\f7be"; } + .bi-feather::before { content: "\f7bf"; } + .bi-feather2::before { content: "\f7c4"; } + .bi-floppy-fill::before { content: "\f7c5"; } + .bi-floppy::before { content: "\f7d8"; } + .bi-floppy2-fill::before { content: "\f7d9"; } + .bi-floppy2::before { content: "\f7e4"; } + .bi-gitlab::before { content: "\f7e5"; } + .bi-highlighter::before { content: "\f7f8"; } + .bi-marker-tip::before { content: "\f802"; } + .bi-nvme-fill::before { content: "\f803"; } + .bi-nvme::before { content: "\f80c"; } + .bi-opencollective::before { content: "\f80d"; } + .bi-pci-card-network::before { content: "\f8cd"; } + .bi-pci-card-sound::before { content: "\f8ce"; } + .bi-radar::before { content: "\f8cf"; } + .bi-send-arrow-down-fill::before { content: "\f8d0"; } + .bi-send-arrow-down::before { content: "\f8d1"; } + .bi-send-arrow-up-fill::before { content: "\f8d2"; } + .bi-send-arrow-up::before { content: "\f8d3"; } + .bi-sim-slash-fill::before { content: "\f8d4"; } + .bi-sim-slash::before { content: "\f8d5"; } + .bi-sourceforge::before { content: "\f8d6"; } + .bi-substack::before { content: "\f8d7"; } + .bi-threads-fill::before { content: "\f8d8"; } + .bi-threads::before { content: "\f8d9"; } + .bi-transparency::before { content: "\f8da"; } + .bi-twitter-x::before { content: "\f8db"; } + .bi-type-h4::before { content: "\f8dc"; } + .bi-type-h5::before { content: "\f8dd"; } + .bi-type-h6::before { content: "\f8de"; } + .bi-backpack-fill::before { content: "\f8df"; } + .bi-backpack::before { content: "\f8e0"; } + .bi-backpack2-fill::before { content: "\f8e1"; } + .bi-backpack2::before { content: "\f8e2"; } + .bi-backpack3-fill::before { content: "\f8e3"; } + .bi-backpack3::before { content: "\f8e4"; } + .bi-backpack4-fill::before { content: "\f8e5"; } + .bi-backpack4::before { content: "\f8e6"; } + .bi-brilliance::before { content: "\f8e7"; } + .bi-cake-fill::before { content: "\f8e8"; } + .bi-cake2-fill::before { content: "\f8e9"; } + .bi-duffle-fill::before { content: "\f8ea"; } + .bi-duffle::before { content: "\f8eb"; } + .bi-exposure::before { content: "\f8ec"; } + .bi-gender-neuter::before { content: "\f8ed"; } + .bi-highlights::before { content: "\f8ee"; } + .bi-luggage-fill::before { content: "\f8ef"; } + .bi-luggage::before { content: "\f8f0"; } + .bi-mailbox-flag::before { content: "\f8f1"; } + .bi-mailbox2-flag::before { content: "\f8f2"; } + .bi-noise-reduction::before { content: "\f8f3"; } + .bi-passport-fill::before { content: "\f8f4"; } + .bi-passport::before { content: "\f8f5"; } + .bi-person-arms-up::before { content: "\f8f6"; } + .bi-person-raised-hand::before { content: "\f8f7"; } + .bi-person-standing-dress::before { content: "\f8f8"; } + .bi-person-standing::before { content: "\f8f9"; } + .bi-person-walking::before { content: "\f8fa"; } + .bi-person-wheelchair::before { content: "\f8fb"; } + .bi-shadows::before { content: "\f8fc"; } + .bi-suitcase-fill::before { content: "\f8fd"; } + .bi-suitcase-lg-fill::before { content: "\f8fe"; } + .bi-suitcase-lg::before { content: "\f8ff"; } + .bi-suitcase::before { content: "\f900"; } + .bi-suitcase2-fill::before { content: "\f901"; } + .bi-suitcase2::before { content: "\f902"; } + .bi-vignette::before { content: "\f903"; } + \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/static/css/bootstrap.min.css b/fst_data_pipeline/apps/mta_manage_system/static/css/bootstrap.min.css new file mode 100644 index 0000000..6561b6f --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/css/bootstrap.min.css @@ -0,0 +1,7 @@ +/*! + * Bootstrap v4.0.0 (https://getbootstrap.com) + * Copyright 2011-2018 The Bootstrap Authors + * Copyright 2011-2018 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg:not(:root){overflow:hidden}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-family:inherit;font-weight:500;line-height:1.2;color:inherit}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#6c757d}.blockquote-footer::before{content:"\2014 \00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}code{font-size:87.5%;color:#e83e8c;word-break:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;min-height:1px;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-1{-webkit-box-flex:0;-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-webkit-box-flex:0;-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-webkit-box-flex:0;-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-webkit-box-flex:0;-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-webkit-box-flex:0;-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-webkit-box-flex:0;-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-webkit-box-flex:0;-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-webkit-box-flex:0;-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-sm-1{-webkit-box-flex:0;-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-webkit-box-flex:0;-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-webkit-box-flex:0;-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-webkit-box-flex:0;-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-webkit-box-flex:0;-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-webkit-box-flex:0;-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-webkit-box-flex:0;-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-webkit-box-flex:0;-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-sm-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-sm-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-sm-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-sm-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-sm-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-sm-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-sm-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-sm-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-sm-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-sm-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-sm-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-sm-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-sm-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-sm-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-md-1{-webkit-box-flex:0;-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-webkit-box-flex:0;-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-webkit-box-flex:0;-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-webkit-box-flex:0;-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-webkit-box-flex:0;-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-webkit-box-flex:0;-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-webkit-box-flex:0;-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-webkit-box-flex:0;-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-md-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-md-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-md-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-md-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-md-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-md-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-md-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-md-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-md-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-md-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-md-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-md-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-md-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-md-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-lg-1{-webkit-box-flex:0;-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-webkit-box-flex:0;-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-webkit-box-flex:0;-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-webkit-box-flex:0;-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-webkit-box-flex:0;-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-webkit-box-flex:0;-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-webkit-box-flex:0;-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-webkit-box-flex:0;-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-lg-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-lg-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-lg-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-lg-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-lg-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-lg-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-lg-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-lg-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-lg-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-lg-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-lg-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-lg-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-lg-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-lg-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-xl-1{-webkit-box-flex:0;-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-webkit-box-flex:0;-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-webkit-box-flex:0;-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-webkit-box-flex:0;-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-webkit-box-flex:0;-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-webkit-box-flex:0;-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-webkit-box-flex:0;-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-webkit-box-flex:0;-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-xl-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-xl-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-xl-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-xl-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-xl-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-xl-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-xl-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-xl-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-xl-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-xl-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-xl-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-xl-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-xl-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-xl-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;max-width:100%;margin-bottom:1rem;background-color:transparent}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table .table{background-color:#fff}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#212529;border-color:#32383e}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#212529}.table-dark td,.table-dark th,.table-dark thead th{border-color:#32383e}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}select.form-control:not([size]):not([multiple]){height:calc(2.25rem + 2px)}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding-top:.375rem;padding-bottom:.375rem;margin-bottom:0;line-height:1.5;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm,.input-group-lg>.form-control-plaintext.form-control,.input-group-lg>.input-group-append>.form-control-plaintext.btn,.input-group-lg>.input-group-append>.form-control-plaintext.input-group-text,.input-group-lg>.input-group-prepend>.form-control-plaintext.btn,.input-group-lg>.input-group-prepend>.form-control-plaintext.input-group-text,.input-group-sm>.form-control-plaintext.form-control,.input-group-sm>.input-group-append>.form-control-plaintext.btn,.input-group-sm>.input-group-append>.form-control-plaintext.input-group-text,.input-group-sm>.input-group-prepend>.form-control-plaintext.btn,.input-group-sm>.input-group-prepend>.form-control-plaintext.input-group-text{padding-right:0;padding-left:0}.form-control-sm,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-sm>.input-group-append>select.btn:not([size]):not([multiple]),.input-group-sm>.input-group-append>select.input-group-text:not([size]):not([multiple]),.input-group-sm>.input-group-prepend>select.btn:not([size]):not([multiple]),.input-group-sm>.input-group-prepend>select.input-group-text:not([size]):not([multiple]),.input-group-sm>select.form-control:not([size]):not([multiple]),select.form-control-sm:not([size]):not([multiple]){height:calc(1.8125rem + 2px)}.form-control-lg,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-lg>.input-group-append>select.btn:not([size]):not([multiple]),.input-group-lg>.input-group-append>select.input-group-text:not([size]):not([multiple]),.input-group-lg>.input-group-prepend>select.btn:not([size]):not([multiple]),.input-group-lg>.input-group-prepend>select.input-group-text:not([size]):not([multiple]),.input-group-lg>select.form-control:not([size]):not([multiple]),select.form-control-lg:not([size]):not([multiple]){height:calc(2.875rem + 2px)}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#28a745}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.5rem;margin-top:.1rem;font-size:.875rem;line-height:1;color:#fff;background-color:rgba(40,167,69,.8);border-radius:.2rem}.custom-select.is-valid,.form-control.is-valid,.was-validated .custom-select:valid,.was-validated .form-control:valid{border-color:#28a745}.custom-select.is-valid:focus,.form-control.is-valid:focus,.was-validated .custom-select:valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-select.is-valid~.valid-feedback,.custom-select.is-valid~.valid-tooltip,.form-control.is-valid~.valid-feedback,.form-control.is-valid~.valid-tooltip,.was-validated .custom-select:valid~.valid-feedback,.was-validated .custom-select:valid~.valid-tooltip,.was-validated .form-control:valid~.valid-feedback,.was-validated .form-control:valid~.valid-tooltip{display:block}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{background-color:#71dd8a}.custom-control-input.is-valid~.valid-feedback,.custom-control-input.is-valid~.valid-tooltip,.was-validated .custom-control-input:valid~.valid-feedback,.was-validated .custom-control-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(40,167,69,.25)}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label::before,.was-validated .custom-file-input:valid~.custom-file-label::before{border-color:inherit}.custom-file-input.is-valid~.valid-feedback,.custom-file-input.is-valid~.valid-tooltip,.was-validated .custom-file-input:valid~.valid-feedback,.was-validated .custom-file-input:valid~.valid-tooltip{display:block}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.5rem;margin-top:.1rem;font-size:.875rem;line-height:1;color:#fff;background-color:rgba(220,53,69,.8);border-radius:.2rem}.custom-select.is-invalid,.form-control.is-invalid,.was-validated .custom-select:invalid,.was-validated .form-control:invalid{border-color:#dc3545}.custom-select.is-invalid:focus,.form-control.is-invalid:focus,.was-validated .custom-select:invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-select.is-invalid~.invalid-feedback,.custom-select.is-invalid~.invalid-tooltip,.form-control.is-invalid~.invalid-feedback,.form-control.is-invalid~.invalid-tooltip,.was-validated .custom-select:invalid~.invalid-feedback,.was-validated .custom-select:invalid~.invalid-tooltip,.was-validated .form-control:invalid~.invalid-feedback,.was-validated .form-control:invalid~.invalid-tooltip{display:block}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{background-color:#efa2a9}.custom-control-input.is-invalid~.invalid-feedback,.custom-control-input.is-invalid~.invalid-tooltip,.was-validated .custom-control-input:invalid~.invalid-feedback,.was-validated .custom-control-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(220,53,69,.25)}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label::before,.was-validated .custom-file-input:invalid~.custom-file-label::before{border-color:inherit}.custom-file-input.is-invalid~.invalid-feedback,.custom-file-input.is-invalid~.invalid-tooltip,.was-validated .custom-file-input:invalid~.invalid-feedback,.was-validated .custom-file-input:invalid~.invalid-tooltip{display:block}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .input-group{width:auto}.form-inline .form-check{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;text-align:center;white-space:nowrap;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.btn:focus,.btn:hover{text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}.btn:not(:disabled):not(.disabled).active,.btn:not(:disabled):not(.disabled):active{background-image:none}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-primary{color:#007bff;background-color:transparent;background-image:none;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;background-color:transparent;background-image:none;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;background-color:transparent;background-image:none;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;background-color:transparent;background-image:none;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;background-color:transparent;background-image:none;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;background-color:transparent;background-image:none;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;background-color:transparent;background-image:none;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;background-color:transparent;background-image:none;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;background-color:transparent}.btn-link:hover{color:#0056b3;text-decoration:underline;background-color:transparent;border-color:transparent}.btn-link.focus,.btn-link:focus{text-decoration:underline;border-color:transparent;box-shadow:none}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;transition:opacity .15s linear}.fade.show{opacity:1}.collapse{display:none}.collapse.show{display:block}tr.collapse.show{display:table-row}tbody.collapse.show{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}.dropdown,.dropup{position:relative}.dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropup .dropdown-menu{margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;width:0;height:0;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#f8f9fa}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#6c757d;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.btn-group,.btn-group-vertical{position:relative;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group,.btn-group-vertical .btn+.btn,.btn-group-vertical .btn+.btn-group,.btn-group-vertical .btn-group+.btn,.btn-group-vertical .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after{margin-left:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.btn-group-vertical .btn,.btn-group-vertical .btn-group{width:100%}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;margin-bottom:0}.input-group>.custom-file:focus,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control{margin-left:-1px}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::before{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label,.input-group>.custom-file:not(:first-child) .custom-file-label::before{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-webkit-box;display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;z-index:-1;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:active~.custom-control-label::before{color:#fff;background-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{margin-bottom:0}.custom-control-label::before{position:absolute;top:.25rem;left:0;display:block;width:1rem;height:1rem;pointer-events:none;content:"";-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#dee2e6}.custom-control-label::after{position:absolute;top:.25rem;left:0;display:block;width:1rem;height:1rem;content:"";background-repeat:no-repeat;background-position:center center;background-size:50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::before{background-color:#007bff}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::before{background-color:#007bff}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(2.25rem + 2px);padding:.375rem 1.75rem .375rem .75rem;line-height:1.5;color:#495057;vertical-align:middle;background:#fff url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") no-repeat right .75rem center;background-size:8px 10px;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:inset 0 1px 2px rgba(0,0,0,.075),0 0 5px rgba(128,189,255,.5)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{opacity:0}.custom-select-sm{height:calc(1.8125rem + 2px);padding-top:.375rem;padding-bottom:.375rem;font-size:75%}.custom-select-lg{height:calc(2.875rem + 2px);padding-top:.375rem;padding-bottom:.375rem;font-size:125%}.custom-file{position:relative;display:inline-block;width:100%;height:calc(2.25rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(2.25rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-control{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:focus~.custom-file-control::before{border-color:#80bdff}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(2.25rem + 2px);padding:.375rem .75rem;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(calc(2.25rem + 2px) - 1px * 2);padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:1px solid #ced4da;border-radius:0 .25rem .25rem 0}.nav{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar>.container,.navbar>.container-fluid{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler:not(:disabled):not(.disabled){cursor:pointer}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .dropdown-menu-right{right:0;left:auto}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .dropup .dropdown-menu{top:auto;bottom:100%}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .dropdown-menu-right{right:0;left:auto}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .dropup .dropdown-menu{top:auto;bottom:100%}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .dropdown-menu-right{right:0;left:auto}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .dropup .dropdown-menu{top:auto;bottom:100%}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .dropdown-menu-right{right:0;left:auto}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .dropup .dropdown-menu{top:auto;bottom:100%}}.navbar-expand{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .dropdown-menu-right{right:0;left:auto}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .dropup .dropdown-menu{top:auto;bottom:100%}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group:first-child .list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-body{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img{width:100%;border-radius:calc(.25rem - 1px)}.card-img-top{width:100%;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img-bottom{width:100%;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:first-child{border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:first-child .card-header,.card-group>.card:first-child .card-img-top{border-top-right-radius:0}.card-group>.card:first-child .card-footer,.card-group>.card:first-child .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:last-child{border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:last-child .card-header,.card-group>.card:last-child .card-img-top{border-top-left-radius:0}.card-group>.card:last-child .card-footer,.card-group>.card:last-child .card-img-bottom{border-bottom-left-radius:0}.card-group>.card:only-child{border-radius:.25rem}.card-group>.card:only-child .card-header,.card-group>.card:only-child .card-img-top{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card-group>.card:only-child .card-footer,.card-group>.card:only-child .card-img-bottom{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-group>.card:not(:first-child):not(:last-child):not(:only-child){border-radius:0}.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-footer,.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-header,.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-img-bottom,.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-img-top{border-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem}.card-columns .card{display:inline-block;width:100%}}.breadcrumb{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;padding-left:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-webkit-box;display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-link:not(:disabled):not(.disabled){cursor:pointer}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:1;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}.badge-primary[href]:focus,.badge-primary[href]:hover{color:#fff;text-decoration:none;background-color:#0062cc}.badge-secondary{color:#fff;background-color:#6c757d}.badge-secondary[href]:focus,.badge-secondary[href]:hover{color:#fff;text-decoration:none;background-color:#545b62}.badge-success{color:#fff;background-color:#28a745}.badge-success[href]:focus,.badge-success[href]:hover{color:#fff;text-decoration:none;background-color:#1e7e34}.badge-info{color:#fff;background-color:#17a2b8}.badge-info[href]:focus,.badge-info[href]:hover{color:#fff;text-decoration:none;background-color:#117a8b}.badge-warning{color:#212529;background-color:#ffc107}.badge-warning[href]:focus,.badge-warning[href]:hover{color:#212529;text-decoration:none;background-color:#d39e00}.badge-danger{color:#fff;background-color:#dc3545}.badge-danger[href]:focus,.badge-danger[href]:hover{color:#fff;text-decoration:none;background-color:#bd2130}.badge-light{color:#212529;background-color:#f8f9fa}.badge-light[href]:focus,.badge-light[href]:hover{color:#212529;text-decoration:none;background-color:#dae0e5}.badge-dark{color:#fff;background-color:#343a40}.badge-dark[href]:focus,.badge-dark[href]:hover{color:#fff;text-decoration:none;background-color:#1d2124}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-webkit-box;display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;color:#fff;text-align:center;background-color:#007bff;transition:width .6s ease}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}.media{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.media-body{-webkit-box-flex:1;-ms-flex:1;flex:1}.list-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;margin-bottom:-1px;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item:focus,.list-group-item:hover{z-index:1;text-decoration:none}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-flush .list-group-item{border-right:0;border-left:0;border-radius:0}.list-group-flush:first-child .list-group-item:first-child{border-top:0}.list-group-flush:last-child .list-group-item:last-child{border-bottom:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:focus,.close:hover{color:#000;text-decoration:none;opacity:.75}.close:not(:disabled):not(.disabled){cursor:pointer}button.close{padding:0;background-color:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;outline:0}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.show .modal-dialog{-webkit-transform:translate(0,0);transform:translate(0,0)}.modal-dialog-centered{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;min-height:calc(100% - (.5rem * 2))}.modal-content{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:1rem;border-bottom:1px solid #e9ecef;border-top-left-radius:.3rem;border-top-right-radius:.3rem}.modal-header .close{padding:1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;padding:1rem;border-top:1px solid #e9ecef}.modal-footer>:not(:first-child){margin-left:.25rem}.modal-footer>:not(:last-child){margin-right:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-centered{min-height:calc(100% - (1.75rem * 2))}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg{max-width:800px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top] .arrow,.bs-popover-top .arrow{bottom:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=top] .arrow::after,.bs-popover-auto[x-placement^=top] .arrow::before,.bs-popover-top .arrow::after,.bs-popover-top .arrow::before{border-width:.5rem .5rem 0}.bs-popover-auto[x-placement^=top] .arrow::before,.bs-popover-top .arrow::before{bottom:0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top] .arrow::after,.bs-popover-top .arrow::after{bottom:1px;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right] .arrow,.bs-popover-right .arrow{left:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right] .arrow::after,.bs-popover-auto[x-placement^=right] .arrow::before,.bs-popover-right .arrow::after,.bs-popover-right .arrow::before{border-width:.5rem .5rem .5rem 0}.bs-popover-auto[x-placement^=right] .arrow::before,.bs-popover-right .arrow::before{left:0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right] .arrow::after,.bs-popover-right .arrow::after{left:1px;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom] .arrow,.bs-popover-bottom .arrow{top:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=bottom] .arrow::after,.bs-popover-auto[x-placement^=bottom] .arrow::before,.bs-popover-bottom .arrow::after,.bs-popover-bottom .arrow::before{border-width:0 .5rem .5rem .5rem}.bs-popover-auto[x-placement^=bottom] .arrow::before,.bs-popover-bottom .arrow::before{top:0;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom] .arrow::after,.bs-popover-bottom .arrow::after{top:1px;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left] .arrow,.bs-popover-left .arrow{right:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left] .arrow::after,.bs-popover-auto[x-placement^=left] .arrow::before,.bs-popover-left .arrow::after,.bs-popover-left .arrow::before{border-width:.5rem 0 .5rem .5rem}.bs-popover-auto[x-placement^=left] .arrow::before,.bs-popover-left .arrow::before{right:0;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left] .arrow::after,.bs-popover-left .arrow::after{right:1px;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;color:inherit;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-item{position:relative;display:none;-webkit-box-align:center;-ms-flex-align:center;align-items:center;width:100%;transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.carousel-item-next,.carousel-item-prev{position:absolute;top:0}.carousel-item-next.carousel-item-left,.carousel-item-prev.carousel-item-right{-webkit-transform:translateX(0);transform:translateX(0)}@supports ((-webkit-transform-style:preserve-3d) or (transform-style:preserve-3d)){.carousel-item-next.carousel-item-left,.carousel-item-prev.carousel-item-right{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.active.carousel-item-right,.carousel-item-next{-webkit-transform:translateX(100%);transform:translateX(100%)}@supports ((-webkit-transform-style:preserve-3d) or (transform-style:preserve-3d)){.active.carousel-item-right,.carousel-item-next{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.active.carousel-item-left,.carousel-item-prev{-webkit-transform:translateX(-100%);transform:translateX(-100%)}@supports ((-webkit-transform-style:preserve-3d) or (transform-style:preserve-3d)){.active.carousel-item-left,.carousel-item-prev{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:transparent no-repeat center center;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E")}.carousel-control-next-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E")}.carousel-indicators{position:absolute;right:0;bottom:10px;left:0;z-index:15;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{position:relative;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;background-color:rgba(255,255,255,.5)}.carousel-indicators li::before{position:absolute;top:-10px;left:0;display:inline-block;width:100%;height:10px;content:""}.carousel-indicators li::after{position:absolute;bottom:-10px;left:0;display:inline-block;width:100%;height:10px;content:""}.carousel-indicators .active{background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-circle{border-radius:50%!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-webkit-inline-box!important;display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-webkit-inline-box!important;display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-webkit-inline-box!important;display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-webkit-inline-box!important;display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-webkit-inline-box!important;display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-webkit-box!important;display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-webkit-inline-box!important;display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-webkit-box-orient:horizontal!important;-webkit-box-direction:normal!important;-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-webkit-box-orient:vertical!important;-webkit-box-direction:normal!important;-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-webkit-box-orient:horizontal!important;-webkit-box-direction:reverse!important;-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-webkit-box-orient:vertical!important;-webkit-box-direction:reverse!important;-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-start{-webkit-box-pack:start!important;-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-webkit-box-pack:end!important;-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-webkit-box-pack:center!important;-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-webkit-box-pack:justify!important;-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-webkit-box-align:start!important;-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-webkit-box-align:end!important;-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-webkit-box-align:center!important;-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-webkit-box-align:baseline!important;-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-webkit-box-align:stretch!important;-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-webkit-box-orient:horizontal!important;-webkit-box-direction:normal!important;-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-webkit-box-orient:vertical!important;-webkit-box-direction:normal!important;-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-webkit-box-orient:horizontal!important;-webkit-box-direction:reverse!important;-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-webkit-box-orient:vertical!important;-webkit-box-direction:reverse!important;-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-sm-start{-webkit-box-pack:start!important;-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-webkit-box-pack:end!important;-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-webkit-box-pack:center!important;-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-webkit-box-pack:justify!important;-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-webkit-box-align:start!important;-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-webkit-box-align:end!important;-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-webkit-box-align:center!important;-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-webkit-box-align:baseline!important;-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-webkit-box-align:stretch!important;-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-webkit-box-orient:horizontal!important;-webkit-box-direction:normal!important;-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-webkit-box-orient:vertical!important;-webkit-box-direction:normal!important;-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-webkit-box-orient:horizontal!important;-webkit-box-direction:reverse!important;-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-webkit-box-orient:vertical!important;-webkit-box-direction:reverse!important;-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-md-start{-webkit-box-pack:start!important;-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-webkit-box-pack:end!important;-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-webkit-box-pack:center!important;-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-webkit-box-pack:justify!important;-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-webkit-box-align:start!important;-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-webkit-box-align:end!important;-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-webkit-box-align:center!important;-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-webkit-box-align:baseline!important;-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-webkit-box-align:stretch!important;-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-webkit-box-orient:horizontal!important;-webkit-box-direction:normal!important;-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-webkit-box-orient:vertical!important;-webkit-box-direction:normal!important;-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-webkit-box-orient:horizontal!important;-webkit-box-direction:reverse!important;-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-webkit-box-orient:vertical!important;-webkit-box-direction:reverse!important;-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-lg-start{-webkit-box-pack:start!important;-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-webkit-box-pack:end!important;-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-webkit-box-pack:center!important;-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-webkit-box-pack:justify!important;-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-webkit-box-align:start!important;-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-webkit-box-align:end!important;-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-webkit-box-align:center!important;-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-webkit-box-align:baseline!important;-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-webkit-box-align:stretch!important;-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-webkit-box-orient:horizontal!important;-webkit-box-direction:normal!important;-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-webkit-box-orient:vertical!important;-webkit-box-direction:normal!important;-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-webkit-box-orient:horizontal!important;-webkit-box-direction:reverse!important;-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-webkit-box-orient:vertical!important;-webkit-box-direction:reverse!important;-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-xl-start{-webkit-box-pack:start!important;-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-webkit-box-pack:end!important;-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-webkit-box-pack:center!important;-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-webkit-box-pack:justify!important;-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-webkit-box-align:start!important;-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-webkit-box-align:end!important;-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-webkit-box-align:center!important;-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-webkit-box-align:baseline!important;-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-webkit-box-align:stretch!important;-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;-webkit-clip-path:inset(50%);clip-path:inset(50%);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal;-webkit-clip-path:none;clip-path:none}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.text-justify{text-align:justify!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0062cc!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#545b62!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#1e7e34!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#117a8b!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#d39e00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#bd2130!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#dae0e5!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#1d2124!important}.text-muted{color:#6c757d!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/static/css/detail.css b/fst_data_pipeline/apps/mta_manage_system/static/css/detail.css new file mode 100644 index 0000000..65eeb02 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/css/detail.css @@ -0,0 +1,76 @@ +body { font-family: sans-serif; margin: 1em; background: #f7f9fc; } +iframe { +width: 95%; +max-width: 1300px; /* 单个 iframe 最大宽度 */ +height: 800px; /* 保持你原有高度 */ +border: 1px solid #ccc; +margin-top: 1em; +background: white; +box-sizing: border-box; /* 让 padding/border 不额外撑宽 */ +display: block; /* 去除底部空白缝隙 */ +margin-left: auto; +margin-right: auto; +margin-left: 0 ; +} +.image-box { +min-height: 400px; /* 根据你的图片高度设置 */ +display: flex; +align-items: center; +justify-content: center; +background: #f8f9fa; +border-radius: 8px; +overflow: hidden; +} + + +#imagePreview { +max-width: 100%; +max-height: 500px; +object-fit: contain; /* 保持比例 */ +transition: opacity 0.3s ease; +} +#log { max-height: 200px; overflow-y: auto; background: #222; color: #0f0; padding: 0.5em; font-family: monospace; font-size: 0.9em; } +h2 { margin-top: 0.5em; } + + +.button-container { + display: flex; + gap: 15px; + margin-top: 20px; + justify-content: center; +} + +.btn { + padding: 12px 30px; + font-size: 16px; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + min-width: 120px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); +} + +.btn-secondary { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; + box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); +} + +.btn-secondary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(245, 87, 108, 0.6); +} \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/static/css/sweetalert2.css b/fst_data_pipeline/apps/mta_manage_system/static/css/sweetalert2.css new file mode 100644 index 0000000..d760784 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/css/sweetalert2.css @@ -0,0 +1 @@ +:root{--swal2-outline: 0 0 0 3px rgba(100, 150, 200, 0.5);--swal2-container-padding: 0.625em;--swal2-backdrop: rgba(0, 0, 0, 0.4);--swal2-backdrop-transition: background-color 0.15s;--swal2-width: 32em;--swal2-padding: 0 0 1.25em;--swal2-border: none;--swal2-border-radius: 0.3125rem;--swal2-background: white;--swal2-color: #545454;--swal2-show-animation: swal2-show 0.3s;--swal2-hide-animation: swal2-hide 0.15s forwards;--swal2-icon-zoom: 1;--swal2-icon-animations: true;--swal2-title-padding: 0.8em 1em 0;--swal2-html-container-padding: 1em 1.6em 0.3em;--swal2-input-border: 1px solid #d9d9d9;--swal2-input-border-radius: 0.1875em;--swal2-input-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px transparent;--swal2-input-background: transparent;--swal2-input-transition: border-color 0.2s, box-shadow 0.2s;--swal2-input-hover-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px transparent;--swal2-input-focus-border: 1px solid #b4dbed;--swal2-input-focus-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px rgba(100, 150, 200, 0.5);--swal2-progress-step-background: #add8e6;--swal2-validation-message-background: #f0f0f0;--swal2-validation-message-color: #666;--swal2-footer-border-color: #eee;--swal2-footer-background: transparent;--swal2-footer-color: inherit;--swal2-timer-progress-bar-background: rgba(0, 0, 0, 0.3);--swal2-close-button-position: initial;--swal2-close-button-inset: auto;--swal2-close-button-font-size: 2.5em;--swal2-close-button-color: #ccc;--swal2-close-button-transition: color 0.2s, box-shadow 0.2s;--swal2-close-button-outline: initial;--swal2-close-button-box-shadow: inset 0 0 0 3px transparent;--swal2-close-button-focus-box-shadow: inset var(--swal2-outline);--swal2-close-button-hover-transform: none;--swal2-actions-justify-content: center;--swal2-actions-width: auto;--swal2-actions-margin: 1.25em auto 0;--swal2-actions-padding: 0;--swal2-actions-border-radius: 0;--swal2-actions-background: transparent;--swal2-action-button-transition: background-color 0.2s, box-shadow 0.2s;--swal2-action-button-hover: black 10%;--swal2-action-button-active: black 10%;--swal2-confirm-button-box-shadow: none;--swal2-confirm-button-border-radius: 0.25em;--swal2-confirm-button-background-color: #7066e0;--swal2-confirm-button-color: #fff;--swal2-deny-button-box-shadow: none;--swal2-deny-button-border-radius: 0.25em;--swal2-deny-button-background-color: #dc3741;--swal2-deny-button-color: #fff;--swal2-cancel-button-box-shadow: none;--swal2-cancel-button-border-radius: 0.25em;--swal2-cancel-button-background-color: #6e7881;--swal2-cancel-button-color: #fff;--swal2-toast-show-animation: swal2-toast-show 0.5s;--swal2-toast-hide-animation: swal2-toast-hide 0.1s forwards;--swal2-toast-border: none;--swal2-toast-box-shadow: 0 0 1px hsl(0deg 0% 0% / 0.075), 0 1px 2px hsl(0deg 0% 0% / 0.075), 1px 2px 4px hsl(0deg 0% 0% / 0.075), 1px 3px 8px hsl(0deg 0% 0% / 0.075), 2px 4px 16px hsl(0deg 0% 0% / 0.075)}[data-swal2-theme=dark]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white);--swal2-timer-progress-bar-background: rgba(255, 255, 255, 0.7)}@media(prefers-color-scheme: dark){[data-swal2-theme=auto]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white);--swal2-timer-progress-bar-background: rgba(255, 255, 255, 0.7)}}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown){overflow:hidden}body.swal2-height-auto{height:auto !important}body.swal2-no-backdrop .swal2-container{background-color:rgba(0,0,0,0) !important;pointer-events:none}body.swal2-no-backdrop .swal2-container .swal2-popup{pointer-events:all}body.swal2-no-backdrop .swal2-container .swal2-modal{box-shadow:0 0 10px var(--swal2-backdrop)}body.swal2-toast-shown .swal2-container{box-sizing:border-box;width:360px;max-width:100%;background-color:rgba(0,0,0,0);pointer-events:none}body.swal2-toast-shown .swal2-container.swal2-top{inset:0 auto auto 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-top-end,body.swal2-toast-shown .swal2-container.swal2-top-right{inset:0 0 auto auto}body.swal2-toast-shown .swal2-container.swal2-top-start,body.swal2-toast-shown .swal2-container.swal2-top-left{inset:0 auto auto 0}body.swal2-toast-shown .swal2-container.swal2-center-start,body.swal2-toast-shown .swal2-container.swal2-center-left{inset:50% auto auto 0;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-center{inset:50% auto auto 50%;transform:translate(-50%, -50%)}body.swal2-toast-shown .swal2-container.swal2-center-end,body.swal2-toast-shown .swal2-container.swal2-center-right{inset:50% 0 auto auto;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-start,body.swal2-toast-shown .swal2-container.swal2-bottom-left{inset:auto auto 0 0}body.swal2-toast-shown .swal2-container.swal2-bottom{inset:auto auto 0 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-end,body.swal2-toast-shown .swal2-container.swal2-bottom-right{inset:auto 0 0 auto}@media print{body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown){overflow-y:scroll !important}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown)>[aria-hidden=true]{display:none}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown) .swal2-container{position:static !important}}div:where(.swal2-container){display:grid;position:fixed;z-index:1060;inset:0;box-sizing:border-box;grid-template-areas:"top-start top top-end" "center-start center center-end" "bottom-start bottom-center bottom-end";grid-template-rows:minmax(min-content, auto) minmax(min-content, auto) minmax(min-content, auto);height:100%;padding:var(--swal2-container-padding);overflow-x:hidden;transition:var(--swal2-backdrop-transition);-webkit-overflow-scrolling:touch}div:where(.swal2-container).swal2-backdrop-show,div:where(.swal2-container).swal2-noanimation{background:var(--swal2-backdrop)}div:where(.swal2-container).swal2-backdrop-hide{background:rgba(0,0,0,0) !important}div:where(.swal2-container).swal2-top-start,div:where(.swal2-container).swal2-center-start,div:where(.swal2-container).swal2-bottom-start{grid-template-columns:minmax(0, 1fr) auto auto}div:where(.swal2-container).swal2-top,div:where(.swal2-container).swal2-center,div:where(.swal2-container).swal2-bottom{grid-template-columns:auto minmax(0, 1fr) auto}div:where(.swal2-container).swal2-top-end,div:where(.swal2-container).swal2-center-end,div:where(.swal2-container).swal2-bottom-end{grid-template-columns:auto auto minmax(0, 1fr)}div:where(.swal2-container).swal2-top-start>.swal2-popup{align-self:start}div:where(.swal2-container).swal2-top>.swal2-popup{grid-column:2;place-self:start center}div:where(.swal2-container).swal2-top-end>.swal2-popup,div:where(.swal2-container).swal2-top-right>.swal2-popup{grid-column:3;place-self:start end}div:where(.swal2-container).swal2-center-start>.swal2-popup,div:where(.swal2-container).swal2-center-left>.swal2-popup{grid-row:2;align-self:center}div:where(.swal2-container).swal2-center>.swal2-popup{grid-column:2;grid-row:2;place-self:center center}div:where(.swal2-container).swal2-center-end>.swal2-popup,div:where(.swal2-container).swal2-center-right>.swal2-popup{grid-column:3;grid-row:2;place-self:center end}div:where(.swal2-container).swal2-bottom-start>.swal2-popup,div:where(.swal2-container).swal2-bottom-left>.swal2-popup{grid-column:1;grid-row:3;align-self:end}div:where(.swal2-container).swal2-bottom>.swal2-popup{grid-column:2;grid-row:3;place-self:end center}div:where(.swal2-container).swal2-bottom-end>.swal2-popup,div:where(.swal2-container).swal2-bottom-right>.swal2-popup{grid-column:3;grid-row:3;place-self:end end}div:where(.swal2-container).swal2-grow-row>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-column:1/4;width:100%}div:where(.swal2-container).swal2-grow-column>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-row:1/4;align-self:stretch}div:where(.swal2-container).swal2-no-transition{transition:none !important}div:where(.swal2-container)[popover]{width:auto;border:0}div:where(.swal2-container) div:where(.swal2-popup){display:none;position:relative;box-sizing:border-box;grid-template-columns:minmax(0, 100%);width:var(--swal2-width);max-width:100%;padding:var(--swal2-padding);border:var(--swal2-border);border-radius:var(--swal2-border-radius);background:var(--swal2-background);color:var(--swal2-color);font-family:inherit;font-size:1rem;container-name:swal2-popup}div:where(.swal2-container) div:where(.swal2-popup):focus{outline:none}div:where(.swal2-container) div:where(.swal2-popup).swal2-loading{overflow-y:hidden}div:where(.swal2-container) div:where(.swal2-popup).swal2-draggable{cursor:grab}div:where(.swal2-container) div:where(.swal2-popup).swal2-draggable div:where(.swal2-icon){cursor:grab}div:where(.swal2-container) div:where(.swal2-popup).swal2-dragging{cursor:grabbing}div:where(.swal2-container) div:where(.swal2-popup).swal2-dragging div:where(.swal2-icon){cursor:grabbing}div:where(.swal2-container) h2:where(.swal2-title){position:relative;max-width:100%;margin:0;padding:var(--swal2-title-padding);color:inherit;font-size:1.875em;font-weight:600;text-align:center;text-transform:none;overflow-wrap:break-word;cursor:initial}div:where(.swal2-container) div:where(.swal2-actions){display:flex;z-index:1;box-sizing:border-box;flex-wrap:wrap;align-items:center;justify-content:var(--swal2-actions-justify-content);width:var(--swal2-actions-width);margin:var(--swal2-actions-margin);padding:var(--swal2-actions-padding);border-radius:var(--swal2-actions-border-radius);background:var(--swal2-actions-background)}div:where(.swal2-container) div:where(.swal2-loader){display:none;align-items:center;justify-content:center;width:2.2em;height:2.2em;margin:0 1.875em;animation:swal2-rotate-loading 1.5s linear 0s infinite normal;border-width:.25em;border-style:solid;border-radius:100%;border-color:#2778c4 rgba(0,0,0,0) #2778c4 rgba(0,0,0,0)}div:where(.swal2-container) button:where(.swal2-styled){margin:.3125em;padding:.625em 1.1em;transition:var(--swal2-action-button-transition);border:none;box-shadow:0 0 0 3px rgba(0,0,0,0);font-weight:500}div:where(.swal2-container) button:where(.swal2-styled):not([disabled]){cursor:pointer}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm){border-radius:var(--swal2-confirm-button-border-radius);background:initial;background-color:var(--swal2-confirm-button-background-color);box-shadow:var(--swal2-confirm-button-box-shadow);color:var(--swal2-confirm-button-color);font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm):hover{background-color:color-mix(in srgb, var(--swal2-confirm-button-background-color), var(--swal2-action-button-hover))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm):active{background-color:color-mix(in srgb, var(--swal2-confirm-button-background-color), var(--swal2-action-button-active))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny){border-radius:var(--swal2-deny-button-border-radius);background:initial;background-color:var(--swal2-deny-button-background-color);box-shadow:var(--swal2-deny-button-box-shadow);color:var(--swal2-deny-button-color);font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny):hover{background-color:color-mix(in srgb, var(--swal2-deny-button-background-color), var(--swal2-action-button-hover))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny):active{background-color:color-mix(in srgb, var(--swal2-deny-button-background-color), var(--swal2-action-button-active))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel){border-radius:var(--swal2-cancel-button-border-radius);background:initial;background-color:var(--swal2-cancel-button-background-color);box-shadow:var(--swal2-cancel-button-box-shadow);color:var(--swal2-cancel-button-color);font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel):hover{background-color:color-mix(in srgb, var(--swal2-cancel-button-background-color), var(--swal2-action-button-hover))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel):active{background-color:color-mix(in srgb, var(--swal2-cancel-button-background-color), var(--swal2-action-button-active))}div:where(.swal2-container) button:where(.swal2-styled):focus-visible{outline:none;box-shadow:var(--swal2-action-button-focus-box-shadow)}div:where(.swal2-container) button:where(.swal2-styled)[disabled]:not(.swal2-loading){opacity:.4}div:where(.swal2-container) button:where(.swal2-styled)::-moz-focus-inner{border:0}div:where(.swal2-container) div:where(.swal2-footer){margin:1em 0 0;padding:1em 1em 0;border-top:1px solid var(--swal2-footer-border-color);background:var(--swal2-footer-background);color:var(--swal2-footer-color);font-size:1em;text-align:center;cursor:initial}div:where(.swal2-container) .swal2-timer-progress-bar-container{position:absolute;right:0;bottom:0;left:0;grid-column:auto !important;overflow:hidden;border-bottom-right-radius:var(--swal2-border-radius);border-bottom-left-radius:var(--swal2-border-radius)}div:where(.swal2-container) div:where(.swal2-timer-progress-bar){width:100%;height:.25em;background:var(--swal2-timer-progress-bar-background)}div:where(.swal2-container) img:where(.swal2-image){max-width:100%;margin:2em auto 1em;cursor:initial}div:where(.swal2-container) button:where(.swal2-close){position:var(--swal2-close-button-position);inset:var(--swal2-close-button-inset);z-index:2;align-items:center;justify-content:center;width:1.2em;height:1.2em;margin-top:0;margin-right:0;margin-bottom:-1.2em;padding:0;overflow:hidden;transition:var(--swal2-close-button-transition);border:none;border-radius:var(--swal2-border-radius);outline:var(--swal2-close-button-outline);background:rgba(0,0,0,0);color:var(--swal2-close-button-color);font-family:monospace;font-size:var(--swal2-close-button-font-size);cursor:pointer;justify-self:end}div:where(.swal2-container) button:where(.swal2-close):hover{transform:var(--swal2-close-button-hover-transform);background:rgba(0,0,0,0);color:#f27474}div:where(.swal2-container) button:where(.swal2-close):focus-visible{outline:none;box-shadow:var(--swal2-close-button-focus-box-shadow)}div:where(.swal2-container) button:where(.swal2-close)::-moz-focus-inner{border:0}div:where(.swal2-container) div:where(.swal2-html-container){z-index:1;justify-content:center;margin:0;padding:var(--swal2-html-container-padding);overflow:auto;color:inherit;font-size:1.125em;font-weight:normal;line-height:normal;text-align:center;overflow-wrap:break-word;word-break:break-word;cursor:initial}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea),div:where(.swal2-container) select:where(.swal2-select),div:where(.swal2-container) div:where(.swal2-radio),div:where(.swal2-container) label:where(.swal2-checkbox){margin:1em 2em 3px}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea){box-sizing:border-box;width:auto;transition:var(--swal2-input-transition);border:var(--swal2-input-border);border-radius:var(--swal2-input-border-radius);background:var(--swal2-input-background);box-shadow:var(--swal2-input-box-shadow);color:inherit;font-size:1.125em}div:where(.swal2-container) input:where(.swal2-input).swal2-inputerror,div:where(.swal2-container) input:where(.swal2-file).swal2-inputerror,div:where(.swal2-container) textarea:where(.swal2-textarea).swal2-inputerror{border-color:#f27474 !important;box-shadow:0 0 2px #f27474 !important}div:where(.swal2-container) input:where(.swal2-input):hover,div:where(.swal2-container) input:where(.swal2-file):hover,div:where(.swal2-container) textarea:where(.swal2-textarea):hover{box-shadow:var(--swal2-input-hover-box-shadow)}div:where(.swal2-container) input:where(.swal2-input):focus,div:where(.swal2-container) input:where(.swal2-file):focus,div:where(.swal2-container) textarea:where(.swal2-textarea):focus{border:var(--swal2-input-focus-border);outline:none;box-shadow:var(--swal2-input-focus-box-shadow)}div:where(.swal2-container) input:where(.swal2-input)::placeholder,div:where(.swal2-container) input:where(.swal2-file)::placeholder,div:where(.swal2-container) textarea:where(.swal2-textarea)::placeholder{color:#ccc}div:where(.swal2-container) .swal2-range{margin:1em 2em 3px;background:var(--swal2-background)}div:where(.swal2-container) .swal2-range input{width:80%}div:where(.swal2-container) .swal2-range output{width:20%;color:inherit;font-weight:600;text-align:center}div:where(.swal2-container) .swal2-range input,div:where(.swal2-container) .swal2-range output{height:2.625em;padding:0;font-size:1.125em;line-height:2.625em}div:where(.swal2-container) .swal2-input{height:2.625em;padding:0 .75em}div:where(.swal2-container) .swal2-file{width:75%;margin-right:auto;margin-left:auto;background:var(--swal2-input-background);font-size:1.125em}div:where(.swal2-container) .swal2-textarea{height:6.75em;padding:.75em}div:where(.swal2-container) .swal2-select{min-width:50%;max-width:100%;padding:.375em .625em;background:var(--swal2-input-background);color:inherit;font-size:1.125em}div:where(.swal2-container) .swal2-radio,div:where(.swal2-container) .swal2-checkbox{align-items:center;justify-content:center;background:var(--swal2-background);color:inherit}div:where(.swal2-container) .swal2-radio label,div:where(.swal2-container) .swal2-checkbox label{margin:0 .6em;font-size:1.125em}div:where(.swal2-container) .swal2-radio input,div:where(.swal2-container) .swal2-checkbox input{flex-shrink:0;margin:0 .4em}div:where(.swal2-container) label:where(.swal2-input-label){display:flex;justify-content:center;margin:1em auto 0}div:where(.swal2-container) div:where(.swal2-validation-message){align-items:center;justify-content:center;margin:1em 0 0;padding:.625em;overflow:hidden;background:var(--swal2-validation-message-background);color:var(--swal2-validation-message-color);font-size:1em;font-weight:300}div:where(.swal2-container) div:where(.swal2-validation-message)::before{content:"!";display:inline-block;width:1.5em;min-width:1.5em;height:1.5em;margin:0 .625em;border-radius:50%;background-color:#f27474;color:#fff;font-weight:600;line-height:1.5em;text-align:center}div:where(.swal2-container) .swal2-progress-steps{flex-wrap:wrap;align-items:center;max-width:100%;margin:1.25em auto;padding:0;background:rgba(0,0,0,0);font-weight:600}div:where(.swal2-container) .swal2-progress-steps li{display:inline-block;position:relative}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step{z-index:20;flex-shrink:0;width:2em;height:2em;border-radius:2em;background:#2778c4;color:#fff;line-height:2em;text-align:center}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step{background:#2778c4}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step{background:var(--swal2-progress-step-background);color:#fff}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step-line{background:var(--swal2-progress-step-background)}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step-line{z-index:10;flex-shrink:0;width:2.5em;height:.4em;margin:0 -1px;background:#2778c4}div:where(.swal2-icon){position:relative;box-sizing:content-box;justify-content:center;width:5em;height:5em;margin:2.5em auto .6em;zoom:var(--swal2-icon-zoom);border:.25em solid rgba(0,0,0,0);border-radius:50%;border-color:#000;font-family:inherit;line-height:5em;cursor:default;user-select:none}div:where(.swal2-icon) .swal2-icon-content{display:flex;align-items:center;font-size:3.75em}div:where(.swal2-icon).swal2-error{border-color:#f27474;color:#f27474}div:where(.swal2-icon).swal2-error .swal2-x-mark{position:relative;flex-grow:1}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line]{display:block;position:absolute;top:2.3125em;width:2.9375em;height:.3125em;border-radius:.125em;background-color:#f27474}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=left]{left:1.0625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=right]{right:1em;transform:rotate(-45deg)}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-error.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-error.swal2-icon-show .swal2-x-mark{animation:swal2-animate-error-x-mark .5s}}div:where(.swal2-icon).swal2-warning{border-color:#f8bb86;color:#f8bb86}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-warning.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-warning.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .5s}}div:where(.swal2-icon).swal2-info{border-color:#3fc3ee;color:#3fc3ee}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-info.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-info.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .8s}}div:where(.swal2-icon).swal2-question{border-color:#87adbd;color:#87adbd}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-question.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-question.swal2-icon-show .swal2-icon-content{animation:swal2-animate-question-mark .8s}}div:where(.swal2-icon).swal2-success{border-color:#a5dc86;color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line]{position:absolute;width:3.75em;height:7.5em;border-radius:50%}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.4375em;left:-2.0635em;transform:rotate(-45deg);transform-origin:3.75em 3.75em;border-radius:7.5em 0 0 7.5em}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.6875em;left:1.875em;transform:rotate(-45deg);transform-origin:0 3.75em;border-radius:0 7.5em 7.5em 0}div:where(.swal2-icon).swal2-success .swal2-success-ring{position:absolute;z-index:2;top:-0.25em;left:-0.25em;box-sizing:content-box;width:100%;height:100%;border:.25em solid rgba(165,220,134,.3);border-radius:50%}div:where(.swal2-icon).swal2-success .swal2-success-fix{position:absolute;z-index:1;top:.5em;left:1.625em;width:.4375em;height:5.625em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line]{display:block;position:absolute;z-index:2;height:.3125em;border-radius:.125em;background-color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=tip]{top:2.875em;left:.8125em;width:1.5625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=long]{top:2.375em;right:.5em;width:2.9375em;transform:rotate(-45deg)}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-animate-success-line-tip .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-animate-success-line-long .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-circular-line-right{animation:swal2-rotate-success-circular-line 4.25s ease-in}}[class^=swal2]{-webkit-tap-highlight-color:rgba(0,0,0,0)}.swal2-show{animation:var(--swal2-show-animation)}.swal2-hide{animation:var(--swal2-hide-animation)}.swal2-noanimation{transition:none}.swal2-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}.swal2-rtl .swal2-close{margin-right:initial;margin-left:0}.swal2-rtl .swal2-timer-progress-bar{right:0;left:auto}.swal2-toast{box-sizing:border-box;grid-column:1/4 !important;grid-row:1/4 !important;grid-template-columns:min-content auto min-content;padding:1em;overflow-y:hidden;border:var(--swal2-toast-border);background:var(--swal2-background);box-shadow:var(--swal2-toast-box-shadow);pointer-events:all}.swal2-toast>*{grid-column:2}.swal2-toast h2:where(.swal2-title){margin:.5em 1em;padding:0;font-size:1em;text-align:initial}.swal2-toast .swal2-loading{justify-content:center}.swal2-toast input:where(.swal2-input){height:2em;margin:.5em;font-size:1em}.swal2-toast .swal2-validation-message{font-size:1em}.swal2-toast div:where(.swal2-footer){margin:.5em 0 0;padding:.5em 0 0;font-size:.8em}.swal2-toast button:where(.swal2-close){grid-column:3/3;grid-row:1/99;align-self:center;width:.8em;height:.8em;margin:0;font-size:2em}.swal2-toast div:where(.swal2-html-container){margin:.5em 1em;padding:0;overflow:initial;font-size:1em;text-align:initial}.swal2-toast div:where(.swal2-html-container):empty{padding:0}.swal2-toast .swal2-loader{grid-column:1;grid-row:1/99;align-self:center;width:2em;height:2em;margin:.25em}.swal2-toast .swal2-icon{grid-column:1;grid-row:1/99;align-self:center;width:2em;min-width:2em;height:2em;margin:0 .5em 0 0}.swal2-toast .swal2-icon .swal2-icon-content{display:flex;align-items:center;font-size:1.8em;font-weight:bold}.swal2-toast .swal2-icon.swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line]{top:.875em;width:1.375em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left]{left:.3125em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right]{right:.3125em}.swal2-toast div:where(.swal2-actions){justify-content:flex-start;height:auto;margin:0;margin-top:.5em;padding:0 .5em}.swal2-toast button:where(.swal2-styled){margin:.25em .5em;padding:.4em .6em;font-size:1em}.swal2-toast .swal2-success{border-color:#a5dc86}.swal2-toast .swal2-success [class^=swal2-success-circular-line]{position:absolute;width:1.6em;height:3em;border-radius:50%}.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.8em;left:-0.5em;transform:rotate(-45deg);transform-origin:2em 2em;border-radius:4em 0 0 4em}.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.25em;left:.9375em;transform-origin:0 1.5em;border-radius:0 4em 4em 0}.swal2-toast .swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-toast .swal2-success .swal2-success-fix{top:0;left:.4375em;width:.4375em;height:2.6875em}.swal2-toast .swal2-success [class^=swal2-success-line]{height:.3125em}.swal2-toast .swal2-success [class^=swal2-success-line][class$=tip]{top:1.125em;left:.1875em;width:.75em}.swal2-toast .swal2-success [class^=swal2-success-line][class$=long]{top:.9375em;right:.1875em;width:1.375em}@container swal2-popup style(--swal2-icon-animations:true){.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-toast-animate-success-line-tip .75s}.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-toast-animate-success-line-long .75s}}.swal2-toast.swal2-show{animation:var(--swal2-toast-show-animation)}.swal2-toast.swal2-hide{animation:var(--swal2-toast-hide-animation)}@keyframes swal2-show{0%{transform:translate3d(0, -50px, 0) scale(0.9);opacity:0}100%{transform:translate3d(0, 0, 0) scale(1);opacity:1}}@keyframes swal2-hide{0%{transform:translate3d(0, 0, 0) scale(1);opacity:1}100%{transform:translate3d(0, -50px, 0) scale(0.9);opacity:0}}@keyframes swal2-animate-success-line-tip{0%{top:1.1875em;left:.0625em;width:0}54%{top:1.0625em;left:.125em;width:0}70%{top:2.1875em;left:-0.375em;width:3.125em}84%{top:3em;left:1.3125em;width:1.0625em}100%{top:2.8125em;left:.8125em;width:1.5625em}}@keyframes swal2-animate-success-line-long{0%{top:3.375em;right:2.875em;width:0}65%{top:3.375em;right:2.875em;width:0}84%{top:2.1875em;right:0;width:3.4375em}100%{top:2.375em;right:.5em;width:2.9375em}}@keyframes swal2-rotate-success-circular-line{0%{transform:rotate(-45deg)}5%{transform:rotate(-45deg)}12%{transform:rotate(-405deg)}100%{transform:rotate(-405deg)}}@keyframes swal2-animate-error-x-mark{0%{margin-top:1.625em;transform:scale(0.4);opacity:0}50%{margin-top:1.625em;transform:scale(0.4);opacity:0}80%{margin-top:-0.375em;transform:scale(1.15)}100%{margin-top:0;transform:scale(1);opacity:1}}@keyframes swal2-animate-error-icon{0%{transform:rotateX(100deg);opacity:0}100%{transform:rotateX(0deg);opacity:1}}@keyframes swal2-rotate-loading{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}@keyframes swal2-animate-question-mark{0%{transform:rotateY(-360deg)}100%{transform:rotateY(0)}}@keyframes swal2-animate-i-mark{0%{transform:rotateZ(45deg);opacity:0}25%{transform:rotateZ(-25deg);opacity:.4}50%{transform:rotateZ(15deg);opacity:.8}75%{transform:rotateZ(-5deg);opacity:1}100%{transform:rotateX(0);opacity:1}}@keyframes swal2-toast-show{0%{transform:translateY(-0.625em) rotateZ(2deg)}33%{transform:translateY(0) rotateZ(-2deg)}66%{transform:translateY(0.3125em) rotateZ(2deg)}100%{transform:translateY(0) rotateZ(0deg)}}@keyframes swal2-toast-hide{100%{transform:rotateZ(1deg);opacity:0}}@keyframes swal2-toast-animate-success-line-tip{0%{top:.5625em;left:.0625em;width:0}54%{top:.125em;left:.125em;width:0}70%{top:.625em;left:-0.25em;width:1.625em}84%{top:1.0625em;left:.75em;width:.5em}100%{top:1.125em;left:.1875em;width:.75em}}@keyframes swal2-toast-animate-success-line-long{0%{top:1.625em;right:1.375em;width:0}65%{top:1.25em;right:.9375em;width:0}84%{top:.9375em;right:0;width:1.125em}100%{top:.9375em;right:.1875em;width:1.375em}} diff --git a/fst_data_pipeline/apps/mta_manage_system/static/images/image.jpg b/fst_data_pipeline/apps/mta_manage_system/static/images/image.jpg new file mode 100644 index 0000000..66c5642 Binary files /dev/null and b/fst_data_pipeline/apps/mta_manage_system/static/images/image.jpg differ diff --git a/fst_data_pipeline/apps/mta_manage_system/static/images/left.jpg b/fst_data_pipeline/apps/mta_manage_system/static/images/left.jpg new file mode 100644 index 0000000..a6c5807 Binary files /dev/null and b/fst_data_pipeline/apps/mta_manage_system/static/images/left.jpg differ diff --git a/fst_data_pipeline/apps/mta_manage_system/static/images/right.jpg b/fst_data_pipeline/apps/mta_manage_system/static/images/right.jpg new file mode 100644 index 0000000..a272fb2 Binary files /dev/null and b/fst_data_pipeline/apps/mta_manage_system/static/images/right.jpg differ diff --git a/fst_data_pipeline/apps/mta_manage_system/static/js/bootstrap.bundle.min.js b/fst_data_pipeline/apps/mta_manage_system/static/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..cc0a255 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/js/bootstrap.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.1.3 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=(t,e,i)=>{Object.keys(i).forEach((n=>{const s=i[n],r=e[n],a=r&&o(r)?"element":null==(l=r)?`${l}`:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(a))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)}))},l=t=>!(!o(t)||0===t.getClientRects().length)&&"visible"===getComputedStyle(t).getPropertyValue("visibility"),c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",(()=>{p.forEach((t=>t()))})),p.push(e)):e()},_=t=>{"function"==typeof t&&t()},b=(e,i,n=!0)=>{if(!n)return void _(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),_(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},v=(t,e,i,n)=>{let s=t.indexOf(e);if(-1===s)return t[!i&&n?t.length-1:0];const o=t.length;return s+=i?1:-1,n&&(s=(s+o)%o),t[Math.max(0,Math.min(s,o-1))]},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,E=/::\d+$/,A={};let T=1;const O={mouseenter:"mouseover",mouseleave:"mouseout"},C=/^(mouseenter|mouseleave)/i,k=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function x(t){const e=L(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function D(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;sfunction(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=S(e,i,n),l=x(t),c=l[a]||(l[a]={}),h=D(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=L(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&j.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&j.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function I(t,e,i,n,s){const o=D(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function P(t){return t=t.replace(w,""),O[t]||t}const j={on(t,e,i,n){N(t,e,i,n,!1)},one(t,e,i,n){N(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=x(t),c=e.startsWith(".");if(void 0!==o){if(!l||!l[r])return;return void I(t,l,r,o,s?i:null)}c&&Object.keys(l).forEach((i=>{!function(t,e,i,n){const s=e[i]||{};Object.keys(s).forEach((o=>{if(o.includes(n)){const n=s[o];I(t,e,i,n.originalHandler,n.delegationSelector)}}))}(t,l,i,e.slice(1))}));const h=l[r]||{};Object.keys(h).forEach((i=>{const n=i.replace(E,"");if(!a||e.includes(n)){const e=h[i];I(t,l,r,e.originalHandler,e.delegationSelector)}}))},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=f(),s=P(e),o=e!==s,r=k.has(s);let a,l=!0,c=!0,h=!1,d=null;return o&&n&&(a=n.Event(e,i),n(t).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent("HTMLEvents"),d.initEvent(s,l,!0)):d=new CustomEvent(e,{bubbles:l,cancelable:!0}),void 0!==i&&Object.keys(i).forEach((t=>{Object.defineProperty(d,t,{get:()=>i[t]})})),h&&d.preventDefault(),c&&t.dispatchEvent(d),d.defaultPrevented&&void 0!==a&&a.preventDefault(),d}},M=new Map,H={set(t,e,i){M.has(t)||M.set(t,new Map);const n=M.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>M.has(t)&&M.get(t).get(e)||null,remove(t,e){if(!M.has(t))return;const i=M.get(t);i.delete(e),0===i.size&&M.delete(t)}};class B{constructor(t){(t=r(t))&&(this._element=t,H.set(this._element,this.constructor.DATA_KEY,this))}dispose(){H.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY),Object.getOwnPropertyNames(this).forEach((t=>{this[t]=null}))}_queueCallback(t,e,i=!0){b(t,e,i)}static getInstance(t){return H.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.1.3"}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}}const R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),c(this))return;const o=n(this)||this.closest(`.${s}`);t.getOrCreateInstance(o)[e]()}))};class W extends B{static get NAME(){return"alert"}close(){if(j.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=W.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(W,"close"),g(W);const $='[data-bs-toggle="button"]';class z extends B{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=z.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}function q(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function F(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}j.on(document,"click.bs.button.data-api",$,(t=>{t.preventDefault();const e=t.target.closest($);z.getOrCreateInstance(e).toggle()})),g(z);const U={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={};return Object.keys(t.dataset).filter((t=>t.startsWith("bs"))).forEach((i=>{let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=q(t.dataset[i])})),e},getDataAttribute:(t,e)=>q(t.getAttribute(`data-bs-${F(e)}`)),offset(t){const e=t.getBoundingClientRect();return{top:e.top+window.pageYOffset,left:e.left+window.pageXOffset}},position:t=>({top:t.offsetTop,left:t.offsetLeft})},V={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode;for(;n&&n.nodeType===Node.ELEMENT_NODE&&3!==n.nodeType;)n.matches(e)&&i.push(n),n=n.parentNode;return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(", ");return this.find(e,t).filter((t=>!c(t)&&l(t)))}},K="carousel",X={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},Y={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},Q="next",G="prev",Z="left",J="right",tt={ArrowLeft:J,ArrowRight:Z},et="slid.bs.carousel",it="active",nt=".active.carousel-item";class st extends B{constructor(t,e){super(t),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(e),this._indicatorsElement=V.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent),this._addEventListeners()}static get Default(){return X}static get NAME(){return K}next(){this._slide(Q)}nextWhenVisible(){!document.hidden&&l(this._element)&&this.next()}prev(){this._slide(G)}pause(t){t||(this._isPaused=!0),V.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(s(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(t){this._activeElement=V.findOne(nt,this._element);const e=this._getItemIndex(this._activeElement);if(t>this._items.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,et,(()=>this.to(t)));if(e===t)return this.pause(),void this.cycle();const i=t>e?Q:G;this._slide(i,this._items[t])}_getConfig(t){return t={...X,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(K,t,Y),t}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=40)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?J:Z)}_addEventListeners(){this._config.keyboard&&j.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,"mouseenter.bs.carousel",(t=>this.pause(t))),j.on(this._element,"mouseleave.bs.carousel",(t=>this.cycle(t)))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()}_addTouchEventListeners(){const t=t=>this._pointerEvent&&("pen"===t.pointerType||"touch"===t.pointerType),e=e=>{t(e)?this.touchStartX=e.clientX:this._pointerEvent||(this.touchStartX=e.touches[0].clientX)},i=t=>{this.touchDeltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this.touchStartX},n=e=>{t(e)&&(this.touchDeltaX=e.clientX-this.touchStartX),this._handleSwipe(),"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((t=>this.cycle(t)),500+this._config.interval))};V.find(".carousel-item img",this._element).forEach((t=>{j.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()))})),this._pointerEvent?(j.on(this._element,"pointerdown.bs.carousel",(t=>e(t))),j.on(this._element,"pointerup.bs.carousel",(t=>n(t))),this._element.classList.add("pointer-event")):(j.on(this._element,"touchstart.bs.carousel",(t=>e(t))),j.on(this._element,"touchmove.bs.carousel",(t=>i(t))),j.on(this._element,"touchend.bs.carousel",(t=>n(t))))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=tt[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(t){return this._items=t&&t.parentNode?V.find(".carousel-item",t.parentNode):[],this._items.indexOf(t)}_getItemByOrder(t,e){const i=t===Q;return v(this._items,e,i,this._config.wrap)}_triggerSlideEvent(t,e){const i=this._getItemIndex(t),n=this._getItemIndex(V.findOne(nt,this._element));return j.trigger(this._element,"slide.bs.carousel",{relatedTarget:t,direction:e,from:n,to:i})}_setActiveIndicatorElement(t){if(this._indicatorsElement){const e=V.findOne(".active",this._indicatorsElement);e.classList.remove(it),e.removeAttribute("aria-current");const i=V.find("[data-bs-target]",this._indicatorsElement);for(let e=0;e{j.trigger(this._element,et,{relatedTarget:o,direction:d,from:s,to:r})};if(this._element.classList.contains("slide")){o.classList.add(h),u(o),n.classList.add(c),o.classList.add(c);const t=()=>{o.classList.remove(c,h),o.classList.add(it),n.classList.remove(it,h,c),this._isSliding=!1,setTimeout(f,0)};this._queueCallback(t,n,!0)}else n.classList.remove(it),o.classList.add(it),this._isSliding=!1,f();a&&this.cycle()}_directionToOrder(t){return[J,Z].includes(t)?m()?t===Z?G:Q:t===Z?Q:G:t}_orderToDirection(t){return[Q,G].includes(t)?m()?t===G?Z:J:t===G?J:Z:t}static carouselInterface(t,e){const i=st.getOrCreateInstance(t,e);let{_config:n}=i;"object"==typeof e&&(n={...n,...e});const s="string"==typeof e?e:n.slide;if("number"==typeof e)i.to(e);else if("string"==typeof s){if(void 0===i[s])throw new TypeError(`No method named "${s}"`);i[s]()}else n.interval&&n.ride&&(i.pause(),i.cycle())}static jQueryInterface(t){return this.each((function(){st.carouselInterface(this,t)}))}static dataApiClickHandler(t){const e=n(this);if(!e||!e.classList.contains("carousel"))return;const i={...U.getDataAttributes(e),...U.getDataAttributes(this)},s=this.getAttribute("data-bs-slide-to");s&&(i.interval=!1),st.carouselInterface(e,i),s&&st.getInstance(e).to(s),t.preventDefault()}}j.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",st.dataApiClickHandler),j.on(window,"load.bs.carousel.data-api",(()=>{const t=V.find('[data-bs-ride="carousel"]');for(let e=0,i=t.length;et===this._element));null!==s&&o.length&&(this._selector=s,this._triggerArray.push(e))}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return rt}static get NAME(){return ot}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t,e=[];if(this._config.parent){const t=V.find(ut,this._config.parent);e=V.find(".collapse.show, .collapse.collapsing",this._config.parent).filter((e=>!t.includes(e)))}const i=V.findOne(this._selector);if(e.length){const n=e.find((t=>i!==t));if(t=n?pt.getInstance(n):null,t&&t._isTransitioning)return}if(j.trigger(this._element,"show.bs.collapse").defaultPrevented)return;e.forEach((e=>{i!==e&&pt.getOrCreateInstance(e,{toggle:!1}).hide(),t||H.set(e,"bs.collapse",null)}));const n=this._getDimension();this._element.classList.remove(ct),this._element.classList.add(ht),this._element.style[n]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const s=`scroll${n[0].toUpperCase()+n.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct,lt),this._element.style[n]="",j.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[n]=`${this._element[s]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,u(this._element),this._element.classList.add(ht),this._element.classList.remove(ct,lt);const e=this._triggerArray.length;for(let t=0;t{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct),j.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(lt)}_getConfig(t){return(t={...rt,...U.getDataAttributes(this._element),...t}).toggle=Boolean(t.toggle),t.parent=r(t.parent),a(ot,t,at),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=V.find(ut,this._config.parent);V.find(ft,this._config.parent).filter((e=>!t.includes(e))).forEach((t=>{const e=n(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}))}_addAriaAndCollapsedClass(t,e){t.length&&t.forEach((t=>{e?t.classList.remove(dt):t.classList.add(dt),t.setAttribute("aria-expanded",e)}))}static jQueryInterface(t){return this.each((function(){const e={};"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1);const i=pt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,"click.bs.collapse.data-api",ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();const e=i(this);V.find(e).forEach((t=>{pt.getOrCreateInstance(t,{toggle:!1}).toggle()}))})),g(pt);var mt="top",gt="bottom",_t="right",bt="left",vt="auto",yt=[mt,gt,_t,bt],wt="start",Et="end",At="clippingParents",Tt="viewport",Ot="popper",Ct="reference",kt=yt.reduce((function(t,e){return t.concat([e+"-"+wt,e+"-"+Et])}),[]),Lt=[].concat(yt,[vt]).reduce((function(t,e){return t.concat([e,e+"-"+wt,e+"-"+Et])}),[]),xt="beforeRead",Dt="read",St="afterRead",Nt="beforeMain",It="main",Pt="afterMain",jt="beforeWrite",Mt="write",Ht="afterWrite",Bt=[xt,Dt,St,Nt,It,Pt,jt,Mt,Ht];function Rt(t){return t?(t.nodeName||"").toLowerCase():null}function Wt(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function $t(t){return t instanceof Wt(t).Element||t instanceof Element}function zt(t){return t instanceof Wt(t).HTMLElement||t instanceof HTMLElement}function qt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof Wt(t).ShadowRoot||t instanceof ShadowRoot)}const Ft={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];zt(s)&&Rt(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});zt(n)&&Rt(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function Ut(t){return t.split("-")[0]}function Vt(t,e){var i=t.getBoundingClientRect();return{width:i.width/1,height:i.height/1,top:i.top/1,right:i.right/1,bottom:i.bottom/1,left:i.left/1,x:i.left/1,y:i.top/1}}function Kt(t){var e=Vt(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Xt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&qt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Yt(t){return Wt(t).getComputedStyle(t)}function Qt(t){return["table","td","th"].indexOf(Rt(t))>=0}function Gt(t){return(($t(t)?t.ownerDocument:t.document)||window.document).documentElement}function Zt(t){return"html"===Rt(t)?t:t.assignedSlot||t.parentNode||(qt(t)?t.host:null)||Gt(t)}function Jt(t){return zt(t)&&"fixed"!==Yt(t).position?t.offsetParent:null}function te(t){for(var e=Wt(t),i=Jt(t);i&&Qt(i)&&"static"===Yt(i).position;)i=Jt(i);return i&&("html"===Rt(i)||"body"===Rt(i)&&"static"===Yt(i).position)?e:i||function(t){var e=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&zt(t)&&"fixed"===Yt(t).position)return null;for(var i=Zt(t);zt(i)&&["html","body"].indexOf(Rt(i))<0;){var n=Yt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function ee(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}var ie=Math.max,ne=Math.min,se=Math.round;function oe(t,e,i){return ie(t,ne(e,i))}function re(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function ae(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const le={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=Ut(i.placement),l=ee(a),c=[bt,_t].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return re("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:ae(t,yt))}(s.padding,i),d=Kt(o),u="y"===l?mt:bt,f="y"===l?gt:_t,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=te(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,E=oe(v,w,y),A=l;i.modifiersData[n]=((e={})[A]=E,e.centerOffset=E-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Xt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function ce(t){return t.split("-")[1]}var he={top:"auto",right:"auto",bottom:"auto",left:"auto"};function de(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=!0===h?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:se(se(e*n)/n)||0,y:se(se(i*n)/n)||0}}(r):"function"==typeof h?h(r):r,u=d.x,f=void 0===u?0:u,p=d.y,m=void 0===p?0:p,g=r.hasOwnProperty("x"),_=r.hasOwnProperty("y"),b=bt,v=mt,y=window;if(c){var w=te(i),E="clientHeight",A="clientWidth";w===Wt(i)&&"static"!==Yt(w=Gt(i)).position&&"absolute"===a&&(E="scrollHeight",A="scrollWidth"),w=w,s!==mt&&(s!==bt&&s!==_t||o!==Et)||(v=gt,m-=w[E]-n.height,m*=l?1:-1),s!==bt&&(s!==mt&&s!==gt||o!==Et)||(b=_t,f-=w[A]-n.width,f*=l?1:-1)}var T,O=Object.assign({position:a},c&&he);return l?Object.assign({},O,((T={})[v]=_?"0":"",T[b]=g?"0":"",T.transform=(y.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",T)):Object.assign({},O,((e={})[v]=_?m+"px":"",e[b]=g?f+"px":"",e.transform="",e))}const ue={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:Ut(e.placement),variation:ce(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,de(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,de(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var fe={passive:!0};const pe={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=Wt(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,fe)})),a&&l.addEventListener("resize",i.update,fe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,fe)})),a&&l.removeEventListener("resize",i.update,fe)}},data:{}};var me={left:"right",right:"left",bottom:"top",top:"bottom"};function ge(t){return t.replace(/left|right|bottom|top/g,(function(t){return me[t]}))}var _e={start:"end",end:"start"};function be(t){return t.replace(/start|end/g,(function(t){return _e[t]}))}function ve(t){var e=Wt(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function ye(t){return Vt(Gt(t)).left+ve(t).scrollLeft}function we(t){var e=Yt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ee(t){return["html","body","#document"].indexOf(Rt(t))>=0?t.ownerDocument.body:zt(t)&&we(t)?t:Ee(Zt(t))}function Ae(t,e){var i;void 0===e&&(e=[]);var n=Ee(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=Wt(n),r=s?[o].concat(o.visualViewport||[],we(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Ae(Zt(r)))}function Te(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Oe(t,e){return e===Tt?Te(function(t){var e=Wt(t),i=Gt(t),n=e.visualViewport,s=i.clientWidth,o=i.clientHeight,r=0,a=0;return n&&(s=n.width,o=n.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(r=n.offsetLeft,a=n.offsetTop)),{width:s,height:o,x:r+ye(t),y:a}}(t)):zt(e)?function(t){var e=Vt(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Te(function(t){var e,i=Gt(t),n=ve(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ie(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ie(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+ye(t),l=-n.scrollTop;return"rtl"===Yt(s||i).direction&&(a+=ie(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Gt(t)))}function Ce(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?Ut(s):null,r=s?ce(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case mt:e={x:a,y:i.y-n.height};break;case gt:e={x:a,y:i.y+i.height};break;case _t:e={x:i.x+i.width,y:l};break;case bt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?ee(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case wt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Et:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ke(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.boundary,r=void 0===o?At:o,a=i.rootBoundary,l=void 0===a?Tt:a,c=i.elementContext,h=void 0===c?Ot:c,d=i.altBoundary,u=void 0!==d&&d,f=i.padding,p=void 0===f?0:f,m=re("number"!=typeof p?p:ae(p,yt)),g=h===Ot?Ct:Ot,_=t.rects.popper,b=t.elements[u?g:h],v=function(t,e,i){var n="clippingParents"===e?function(t){var e=Ae(Zt(t)),i=["absolute","fixed"].indexOf(Yt(t).position)>=0&&zt(t)?te(t):t;return $t(i)?e.filter((function(t){return $t(t)&&Xt(t,i)&&"body"!==Rt(t)})):[]}(t):[].concat(e),s=[].concat(n,[i]),o=s[0],r=s.reduce((function(e,i){var n=Oe(t,i);return e.top=ie(n.top,e.top),e.right=ne(n.right,e.right),e.bottom=ne(n.bottom,e.bottom),e.left=ie(n.left,e.left),e}),Oe(t,o));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}($t(b)?b:b.contextElement||Gt(t.elements.popper),r,l),y=Vt(t.elements.reference),w=Ce({reference:y,element:_,strategy:"absolute",placement:s}),E=Te(Object.assign({},_,w)),A=h===Ot?E:y,T={top:v.top-A.top+m.top,bottom:A.bottom-v.bottom+m.bottom,left:v.left-A.left+m.left,right:A.right-v.right+m.right},O=t.modifiersData.offset;if(h===Ot&&O){var C=O[s];Object.keys(T).forEach((function(t){var e=[_t,gt].indexOf(t)>=0?1:-1,i=[mt,gt].indexOf(t)>=0?"y":"x";T[t]+=C[i]*e}))}return T}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?Lt:l,h=ce(n),d=h?a?kt:kt.filter((function(t){return ce(t)===h})):yt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ke(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[Ut(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const xe={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=Ut(g),b=l||(_!==g&&p?function(t){if(Ut(t)===vt)return[];var e=ge(t);return[be(t),e,be(e)]}(g):[ge(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(Ut(i)===vt?Le(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,A=!0,T=v[0],O=0;O=0,D=x?"width":"height",S=ke(e,{placement:C,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),N=x?L?_t:bt:L?gt:mt;y[D]>w[D]&&(N=ge(N));var I=ge(N),P=[];if(o&&P.push(S[k]<=0),a&&P.push(S[N]<=0,S[I]<=0),P.every((function(t){return t}))){T=C,A=!1;break}E.set(C,P)}if(A)for(var j=function(t){var e=v.find((function(e){var i=E.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==j(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function De(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Se(t){return[mt,_t,gt,bt].some((function(e){return t[e]>=0}))}const Ne={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=De(r,n),c=De(a,s,o),h=Se(l),d=Se(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},Ie={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=Lt.reduce((function(t,i){return t[i]=function(t,e,i){var n=Ut(t),s=[bt,mt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[bt,_t].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},Pe={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=Ce({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},je={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=Ut(e.placement),b=ce(e.placement),v=!b,y=ee(_),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,A=e.rects.reference,T=e.rects.popper,O="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,C={x:0,y:0};if(E){if(o||a){var k="y"===y?mt:bt,L="y"===y?gt:_t,x="y"===y?"height":"width",D=E[y],S=E[y]+g[k],N=E[y]-g[L],I=f?-T[x]/2:0,P=b===wt?A[x]:T[x],j=b===wt?-T[x]:-A[x],M=e.elements.arrow,H=f&&M?Kt(M):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},R=B[k],W=B[L],$=oe(0,A[x],H[x]),z=v?A[x]/2-I-$-R-O:P-$-R-O,q=v?-A[x]/2+I+$+W+O:j+$+W+O,F=e.elements.arrow&&te(e.elements.arrow),U=F?"y"===y?F.clientTop||0:F.clientLeft||0:0,V=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,K=E[y]+z-V-U,X=E[y]+q-V;if(o){var Y=oe(f?ne(S,K):S,D,f?ie(N,X):N);E[y]=Y,C[y]=Y-D}if(a){var Q="x"===y?mt:bt,G="x"===y?gt:_t,Z=E[w],J=Z+g[Q],tt=Z-g[G],et=oe(f?ne(J,K):J,Z,f?ie(tt,X):tt);E[w]=et,C[w]=et-Z}}e.modifiersData[n]=C}},requiresIfExists:["offset"]};function Me(t,e,i){void 0===i&&(i=!1);var n=zt(e);zt(e)&&function(t){var e=t.getBoundingClientRect();e.width,t.offsetWidth,e.height,t.offsetHeight}(e);var s,o,r=Gt(e),a=Vt(t),l={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(n||!n&&!i)&&(("body"!==Rt(e)||we(r))&&(l=(s=e)!==Wt(s)&&zt(s)?{scrollLeft:(o=s).scrollLeft,scrollTop:o.scrollTop}:ve(s)),zt(e)?((c=Vt(e)).x+=e.clientLeft,c.y+=e.clientTop):r&&(c.x=ye(r))),{x:a.left+l.scrollLeft-c.x,y:a.top+l.scrollTop-c.y,width:a.width,height:a.height}}function He(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var Be={placement:"bottom",modifiers:[],strategy:"absolute"};function Re(){for(var t=arguments.length,e=new Array(t),i=0;ij.on(t,"mouseover",d))),this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(Je),this._element.classList.add(Je),j.trigger(this._element,"shown.bs.dropdown",t)}hide(){if(c(this._element)||!this._isShown(this._menu))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){j.trigger(this._element,"hide.bs.dropdown",t).defaultPrevented||("ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._popper&&this._popper.destroy(),this._menu.classList.remove(Je),this._element.classList.remove(Je),this._element.setAttribute("aria-expanded","false"),U.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,"hidden.bs.dropdown",t))}_getConfig(t){if(t={...this.constructor.Default,...U.getDataAttributes(this._element),...t},a(Ue,t,this.constructor.DefaultType),"object"==typeof t.reference&&!o(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Ue.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(t){if(void 0===Fe)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let e=this._element;"parent"===this._config.reference?e=t:o(this._config.reference)?e=r(this._config.reference):"object"==typeof this._config.reference&&(e=this._config.reference);const i=this._getPopperConfig(),n=i.modifiers.find((t=>"applyStyles"===t.name&&!1===t.enabled));this._popper=qe(e,this._menu,i),n&&U.setDataAttribute(this._menu,"popper","static")}_isShown(t=this._element){return t.classList.contains(Je)}_getMenuElement(){return V.next(this._element,ei)[0]}_getPlacement(){const t=this._element.parentNode;if(t.classList.contains("dropend"))return ri;if(t.classList.contains("dropstart"))return ai;const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?ni:ii:e?oi:si}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return"static"===this._config.display&&(t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:t,target:e}){const i=V.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(l);i.length&&v(i,e,t===Ye,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(t&&(2===t.button||"keyup"===t.type&&"Tab"!==t.key))return;const e=V.find(ti);for(let i=0,n=e.length;ie+t)),this._setElementAttributes(di,"paddingRight",(e=>e+t)),this._setElementAttributes(ui,"marginRight",(e=>e-t))}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t)[e];t.style[e]=`${i(Number.parseFloat(s))}px`}))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,"paddingRight"),this._resetElementAttributes(di,"paddingRight"),this._resetElementAttributes(ui,"marginRight")}_saveInitialAttribute(t,e){const i=t.style[e];i&&U.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=U.getDataAttribute(t,e);void 0===i?t.style.removeProperty(e):(U.removeDataAttribute(t,e),t.style[e]=i)}))}_applyManipulationCallback(t,e){o(t)?e(t):V.find(t,this._element).forEach(e)}isOverflowing(){return this.getWidth()>0}}const pi={className:"modal-backdrop",isVisible:!0,isAnimated:!1,rootElement:"body",clickCallback:null},mi={className:"string",isVisible:"boolean",isAnimated:"boolean",rootElement:"(element|string)",clickCallback:"(function|null)"},gi="show",_i="mousedown.bs.backdrop";class bi{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){this._config.isVisible?(this._append(),this._config.isAnimated&&u(this._getElement()),this._getElement().classList.add(gi),this._emulateAnimation((()=>{_(t)}))):_(t)}hide(t){this._config.isVisible?(this._getElement().classList.remove(gi),this._emulateAnimation((()=>{this.dispose(),_(t)}))):_(t)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_getConfig(t){return(t={...pi,..."object"==typeof t?t:{}}).rootElement=r(t.rootElement),a("backdrop",t,mi),t}_append(){this._isAppended||(this._config.rootElement.append(this._getElement()),j.on(this._getElement(),_i,(()=>{_(this._config.clickCallback)})),this._isAppended=!0)}dispose(){this._isAppended&&(j.off(this._element,_i),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){b(t,this._getElement(),this._config.isAnimated)}}const vi={trapElement:null,autofocus:!0},yi={trapElement:"element",autofocus:"boolean"},wi=".bs.focustrap",Ei="backward";class Ai{constructor(t){this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}activate(){const{trapElement:t,autofocus:e}=this._config;this._isActive||(e&&t.focus(),j.off(document,wi),j.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),j.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,wi))}_handleFocusin(t){const{target:e}=t,{trapElement:i}=this._config;if(e===document||e===i||i.contains(e))return;const n=V.focusableChildren(i);0===n.length?i.focus():this._lastTabNavDirection===Ei?n[n.length-1].focus():n[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Ei:"forward")}_getConfig(t){return t={...vi,..."object"==typeof t?t:{}},a("focustrap",t,yi),t}}const Ti="modal",Oi="Escape",Ci={backdrop:!0,keyboard:!0,focus:!0},ki={backdrop:"(boolean|string)",keyboard:"boolean",focus:"boolean"},Li="hidden.bs.modal",xi="show.bs.modal",Di="resize.bs.modal",Si="click.dismiss.bs.modal",Ni="keydown.dismiss.bs.modal",Ii="mousedown.dismiss.bs.modal",Pi="modal-open",ji="show",Mi="modal-static";class Hi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._dialog=V.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new fi}static get Default(){return Ci}static get NAME(){return Ti}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,xi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),this._scrollBar.hide(),document.body.classList.add(Pi),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),j.on(this._dialog,Ii,(()=>{j.one(this._element,"mouseup.dismiss.bs.modal",(t=>{t.target===this._element&&(this._ignoreBackdropClick=!0)}))})),this._showBackdrop((()=>this._showElement(t))))}hide(){if(!this._isShown||this._isTransitioning)return;if(j.trigger(this._element,"hide.bs.modal").defaultPrevented)return;this._isShown=!1;const t=this._isAnimated();t&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),this._focustrap.deactivate(),this._element.classList.remove(ji),j.off(this._element,Si),j.off(this._dialog,Ii),this._queueCallback((()=>this._hideModal()),this._element,t)}dispose(){[window,this._dialog].forEach((t=>j.off(t,".bs.modal"))),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new bi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_getConfig(t){return t={...Ci,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Ti,t,ki),t}_showElement(t){const e=this._isAnimated(),i=V.findOne(".modal-body",this._dialog);this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0,i&&(i.scrollTop=0),e&&u(this._element),this._element.classList.add(ji),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,e)}_setEscapeEvent(){this._isShown?j.on(this._element,Ni,(t=>{this._config.keyboard&&t.key===Oi?(t.preventDefault(),this.hide()):this._config.keyboard||t.key!==Oi||this._triggerBackdropTransition()})):j.off(this._element,Ni)}_setResizeEvent(){this._isShown?j.on(window,Di,(()=>this._adjustDialog())):j.off(window,Di)}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Pi),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,Li)}))}_showBackdrop(t){j.on(this._element,Si,(t=>{this._ignoreBackdropClick?this._ignoreBackdropClick=!1:t.target===t.currentTarget&&(!0===this._config.backdrop?this.hide():"static"===this._config.backdrop&&this._triggerBackdropTransition())})),this._backdrop.show(t)}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const{classList:t,scrollHeight:e,style:i}=this._element,n=e>document.documentElement.clientHeight;!n&&"hidden"===i.overflowY||t.contains(Mi)||(n||(i.overflowY="hidden"),t.add(Mi),this._queueCallback((()=>{t.remove(Mi),n||this._queueCallback((()=>{i.overflowY=""}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!m()||i&&!t&&m())&&(this._element.style.paddingLeft=`${e}px`),(i&&!t&&!m()||!i&&t&&m())&&(this._element.style.paddingRight=`${e}px`)}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=n(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,xi,(t=>{t.defaultPrevented||j.one(e,Li,(()=>{l(this)&&this.focus()}))}));const i=V.findOne(".modal.show");i&&Hi.getInstance(i).hide(),Hi.getOrCreateInstance(e).toggle(this)})),R(Hi),g(Hi);const Bi="offcanvas",Ri={backdrop:!0,keyboard:!0,scroll:!1},Wi={backdrop:"boolean",keyboard:"boolean",scroll:"boolean"},$i="show",zi=".offcanvas.show",qi="hidden.bs.offcanvas";class Fi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get NAME(){return Bi}static get Default(){return Ri}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._element.style.visibility="visible",this._backdrop.show(),this._config.scroll||(new fi).hide(),this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add($i),this._queueCallback((()=>{this._config.scroll||this._focustrap.activate(),j.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.remove($i),this._backdrop.hide(),this._queueCallback((()=>{this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._element.style.visibility="hidden",this._config.scroll||(new fi).reset(),j.trigger(this._element,qi)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_getConfig(t){return t={...Ri,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Bi,t,Wi),t}_initializeBackDrop(){return new bi({className:"offcanvas-backdrop",isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_addEventListeners(){j.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{this._config.keyboard&&"Escape"===t.key&&this.hide()}))}static jQueryInterface(t){return this.each((function(){const e=Fi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=n(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this))return;j.one(e,qi,(()=>{l(this)&&this.focus()}));const i=V.findOne(zi);i&&i!==e&&Fi.getInstance(i).hide(),Fi.getOrCreateInstance(e).toggle(this)})),j.on(window,"load.bs.offcanvas.data-api",(()=>V.find(zi).forEach((t=>Fi.getOrCreateInstance(t).show())))),R(Fi),g(Fi);const Ui=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Vi=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,Ki=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,Xi=(t,e)=>{const i=t.nodeName.toLowerCase();if(e.includes(i))return!Ui.has(i)||Boolean(Vi.test(t.nodeValue)||Ki.test(t.nodeValue));const n=e.filter((t=>t instanceof RegExp));for(let t=0,e=n.length;t{Xi(t,r)||i.removeAttribute(t.nodeName)}))}return n.body.innerHTML}const Qi="tooltip",Gi=new Set(["sanitize","allowList","sanitizeFn"]),Zi={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(array|string|function)",container:"(string|element|boolean)",fallbackPlacements:"array",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",allowList:"object",popperConfig:"(null|object|function)"},Ji={AUTO:"auto",TOP:"top",RIGHT:m()?"left":"right",BOTTOM:"bottom",LEFT:m()?"right":"left"},tn={animation:!0,template:'',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,fallbackPlacements:["top","right","bottom","left"],boundary:"clippingParents",customClass:"",sanitize:!0,sanitizeFn:null,allowList:{"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},popperConfig:null},en={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"},nn="fade",sn="show",on="show",rn="out",an=".tooltip-inner",ln=".modal",cn="hide.bs.modal",hn="hover",dn="focus";class un extends B{constructor(t,e){if(void 0===Fe)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return tn}static get NAME(){return Qi}static get Event(){return en}static get DefaultType(){return Zi}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains(sn))return void this._leave(null,this);this._enter(null,this)}}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ln),cn,this._hideModalHandler),this.tip&&this.tip.remove(),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this.isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.Event.SHOW),e=h(this._element),i=null===e?this._element.ownerDocument.documentElement.contains(this._element):e.contains(this._element);if(t.defaultPrevented||!i)return;"tooltip"===this.constructor.NAME&&this.tip&&this.getTitle()!==this.tip.querySelector(an).innerHTML&&(this._disposePopper(),this.tip.remove(),this.tip=null);const n=this.getTipElement(),s=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME);n.setAttribute("id",s),this._element.setAttribute("aria-describedby",s),this._config.animation&&n.classList.add(nn);const o="function"==typeof this._config.placement?this._config.placement.call(this,n,this._element):this._config.placement,r=this._getAttachment(o);this._addAttachmentClass(r);const{container:a}=this._config;H.set(n,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(a.append(n),j.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=qe(this._element,n,this._getPopperConfig(r)),n.classList.add(sn);const l=this._resolvePossibleFunction(this._config.customClass);l&&n.classList.add(...l.split(" ")),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>{j.on(t,"mouseover",d)}));const c=this.tip.classList.contains(nn);this._queueCallback((()=>{const t=this._hoverState;this._hoverState=null,j.trigger(this._element,this.constructor.Event.SHOWN),t===rn&&this._leave(null,this)}),this.tip,c)}hide(){if(!this._popper)return;const t=this.getTipElement();if(j.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.remove(sn),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1;const e=this.tip.classList.contains(nn);this._queueCallback((()=>{this._isWithActiveTrigger()||(this._hoverState!==on&&t.remove(),this._cleanTipClass(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.Event.HIDDEN),this._disposePopper())}),this.tip,e),this._hoverState=""}update(){null!==this._popper&&this._popper.update()}isWithContent(){return Boolean(this.getTitle())}getTipElement(){if(this.tip)return this.tip;const t=document.createElement("div");t.innerHTML=this._config.template;const e=t.children[0];return this.setContent(e),e.classList.remove(nn,sn),this.tip=e,this.tip}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),an)}_sanitizeAndSetContent(t,e,i){const n=V.findOne(i,t);e||!n?this.setElementContent(n,e):n.remove()}setElementContent(t,e){if(null!==t)return o(e)?(e=r(e),void(this._config.html?e.parentNode!==t&&(t.innerHTML="",t.append(e)):t.textContent=e.textContent)):void(this._config.html?(this._config.sanitize&&(e=Yi(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e)}getTitle(){const t=this._element.getAttribute("data-bs-original-title")||this._config.title;return this._resolvePossibleFunction(t)}updateAttachment(t){return"right"===t?"end":"left"===t?"start":t}_initializeOnDelegatedTarget(t,e){return e||this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return"function"==typeof t?t.call(this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"onChange",enabled:!0,phase:"afterWrite",fn:t=>this._handlePopperPlacementChange(t)}],onFirstUpdate:t=>{t.options.placement!==t.placement&&this._handlePopperPlacementChange(t)}};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(t)}`)}_getAttachment(t){return Ji[t.toUpperCase()]}_setListeners(){this._config.trigger.split(" ").forEach((t=>{if("click"===t)j.on(this._element,this.constructor.Event.CLICK,this._config.selector,(t=>this.toggle(t)));else if("manual"!==t){const e=t===hn?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,i=t===hn?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;j.on(this._element,e,this._config.selector,(t=>this._enter(t))),j.on(this._element,i,this._config.selector,(t=>this._leave(t)))}})),this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ln),cn,this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:"manual",selector:""}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute("title"),e=typeof this._element.getAttribute("data-bs-original-title");(t||"string"!==e)&&(this._element.setAttribute("data-bs-original-title",t||""),!t||this._element.getAttribute("aria-label")||this._element.textContent||this._element.setAttribute("aria-label",t),this._element.setAttribute("title",""))}_enter(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusin"===t.type?dn:hn]=!0),e.getTipElement().classList.contains(sn)||e._hoverState===on?e._hoverState=on:(clearTimeout(e._timeout),e._hoverState=on,e._config.delay&&e._config.delay.show?e._timeout=setTimeout((()=>{e._hoverState===on&&e.show()}),e._config.delay.show):e.show())}_leave(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusout"===t.type?dn:hn]=e._element.contains(t.relatedTarget)),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=rn,e._config.delay&&e._config.delay.hide?e._timeout=setTimeout((()=>{e._hoverState===rn&&e.hide()}),e._config.delay.hide):e.hide())}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=U.getDataAttributes(this._element);return Object.keys(e).forEach((t=>{Gi.has(t)&&delete e[t]})),(t={...this.constructor.Default,...e,..."object"==typeof t&&t?t:{}}).container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),a(Qi,t,this.constructor.DefaultType),t.sanitize&&(t.template=Yi(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=new RegExp(`(^|\\s)${this._getBasicClassPrefix()}\\S+`,"g"),i=t.getAttribute("class").match(e);null!==i&&i.length>0&&i.map((t=>t.trim())).forEach((e=>t.classList.remove(e)))}_getBasicClassPrefix(){return"bs-tooltip"}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null)}static jQueryInterface(t){return this.each((function(){const e=un.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(un);const fn={...un.Default,placement:"right",offset:[0,8],trigger:"click",content:"",template:''},pn={...un.DefaultType,content:"(string|element|function)"},mn={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"};class gn extends un{static get Default(){return fn}static get NAME(){return"popover"}static get Event(){return mn}static get DefaultType(){return pn}isWithContent(){return this.getTitle()||this._getContent()}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),".popover-header"),this._sanitizeAndSetContent(t,this._getContent(),".popover-body")}_getContent(){return this._resolvePossibleFunction(this._config.content)}_getBasicClassPrefix(){return"bs-popover"}static jQueryInterface(t){return this.each((function(){const e=gn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(gn);const _n="scrollspy",bn={offset:10,method:"auto",target:""},vn={offset:"number",method:"string",target:"(string|element)"},yn="active",wn=".nav-link, .list-group-item, .dropdown-item",En="position";class An extends B{constructor(t,e){super(t),this._scrollElement="BODY"===this._element.tagName?window:this._element,this._config=this._getConfig(e),this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,j.on(this._scrollElement,"scroll.bs.scrollspy",(()=>this._process())),this.refresh(),this._process()}static get Default(){return bn}static get NAME(){return _n}refresh(){const t=this._scrollElement===this._scrollElement.window?"offset":En,e="auto"===this._config.method?t:this._config.method,n=e===En?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),V.find(wn,this._config.target).map((t=>{const s=i(t),o=s?V.findOne(s):null;if(o){const t=o.getBoundingClientRect();if(t.width||t.height)return[U[e](o).top+n,s]}return null})).filter((t=>t)).sort(((t,e)=>t[0]-e[0])).forEach((t=>{this._offsets.push(t[0]),this._targets.push(t[1])}))}dispose(){j.off(this._scrollElement,".bs.scrollspy"),super.dispose()}_getConfig(t){return(t={...bn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}}).target=r(t.target)||document.documentElement,a(_n,t,vn),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const t=this._targets[this._targets.length-1];this._activeTarget!==t&&this._activate(t)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(let e=this._offsets.length;e--;)this._activeTarget!==this._targets[e]&&t>=this._offsets[e]&&(void 0===this._offsets[e+1]||t`${e}[data-bs-target="${t}"],${e}[href="${t}"]`)),i=V.findOne(e.join(","),this._config.target);i.classList.add(yn),i.classList.contains("dropdown-item")?V.findOne(".dropdown-toggle",i.closest(".dropdown")).classList.add(yn):V.parents(i,".nav, .list-group").forEach((t=>{V.prev(t,".nav-link, .list-group-item").forEach((t=>t.classList.add(yn))),V.prev(t,".nav-item").forEach((t=>{V.children(t,".nav-link").forEach((t=>t.classList.add(yn)))}))})),j.trigger(this._scrollElement,"activate.bs.scrollspy",{relatedTarget:t})}_clear(){V.find(wn,this._config.target).filter((t=>t.classList.contains(yn))).forEach((t=>t.classList.remove(yn)))}static jQueryInterface(t){return this.each((function(){const e=An.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,"load.bs.scrollspy.data-api",(()=>{V.find('[data-bs-spy="scroll"]').forEach((t=>new An(t)))})),g(An);const Tn="active",On="fade",Cn="show",kn=".active",Ln=":scope > li > .active";class xn extends B{static get NAME(){return"tab"}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains(Tn))return;let t;const e=n(this._element),i=this._element.closest(".nav, .list-group");if(i){const e="UL"===i.nodeName||"OL"===i.nodeName?Ln:kn;t=V.find(e,i),t=t[t.length-1]}const s=t?j.trigger(t,"hide.bs.tab",{relatedTarget:this._element}):null;if(j.trigger(this._element,"show.bs.tab",{relatedTarget:t}).defaultPrevented||null!==s&&s.defaultPrevented)return;this._activate(this._element,i);const o=()=>{j.trigger(t,"hidden.bs.tab",{relatedTarget:this._element}),j.trigger(this._element,"shown.bs.tab",{relatedTarget:t})};e?this._activate(e,e.parentNode,o):o()}_activate(t,e,i){const n=(!e||"UL"!==e.nodeName&&"OL"!==e.nodeName?V.children(e,kn):V.find(Ln,e))[0],s=i&&n&&n.classList.contains(On),o=()=>this._transitionComplete(t,n,i);n&&s?(n.classList.remove(Cn),this._queueCallback(o,t,!0)):o()}_transitionComplete(t,e,i){if(e){e.classList.remove(Tn);const t=V.findOne(":scope > .dropdown-menu .active",e.parentNode);t&&t.classList.remove(Tn),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!1)}t.classList.add(Tn),"tab"===t.getAttribute("role")&&t.setAttribute("aria-selected",!0),u(t),t.classList.contains(On)&&t.classList.add(Cn);let n=t.parentNode;if(n&&"LI"===n.nodeName&&(n=n.parentNode),n&&n.classList.contains("dropdown-menu")){const e=t.closest(".dropdown");e&&V.find(".dropdown-toggle",e).forEach((t=>t.classList.add(Tn))),t.setAttribute("aria-expanded",!0)}i&&i()}static jQueryInterface(t){return this.each((function(){const e=xn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,"click.bs.tab.data-api",'[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this)||xn.getOrCreateInstance(this).show()})),g(xn);const Dn="toast",Sn="hide",Nn="show",In="showing",Pn={animation:"boolean",autohide:"boolean",delay:"number"},jn={animation:!0,autohide:!0,delay:5e3};class Mn extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get DefaultType(){return Pn}static get Default(){return jn}static get NAME(){return Dn}show(){j.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(Sn),u(this._element),this._element.classList.add(Nn),this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.remove(In),j.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this._element.classList.contains(Nn)&&(j.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.add(Sn),this._element.classList.remove(In),this._element.classList.remove(Nn),j.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this._element.classList.contains(Nn)&&this._element.classList.remove(Nn),super.dispose()}_getConfig(t){return t={...jn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}},a(Dn,t,this.constructor.DefaultType),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),j.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Mn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(Mn),g(Mn),{Alert:W,Button:z,Carousel:st,Collapse:pt,Dropdown:hi,Modal:Hi,Offcanvas:Fi,Popover:gn,ScrollSpy:An,Tab:xn,Toast:Mn,Tooltip:un}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/static/js/detail.js b/fst_data_pipeline/apps/mta_manage_system/static/js/detail.js new file mode 100644 index 0000000..5d5c4f2 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/js/detail.js @@ -0,0 +1,139 @@ +'use strict'; + + +// const bag_folder = $('#hiddenBagFolder').attr('value'); + +// console.log('Bag Folder:', bag_folder); + function getJpgFileName(path) { + + // 统一斜杠并去除末尾可能的查询参数 + const clean = path.replace(/\\/g, '/').split('?')[0].trim(); + // 判断是否以 .jpg 结尾(忽略大小写) + const isJpg = /\.jpg$/i.test(clean); + if (!isJpg) return ''; // 不以 .jpg 结尾则返回空 + + // 从最后一个斜杠后截取到末尾 + const idx = clean.lastIndexOf('/'); + return idx === -1 ? clean : clean.slice(idx + 1); + } + + function showImage(imagename,bagname,htmlname,bag_folder) { + console.log('htmlname:'+htmlname) ; + // alert(bagname) + $("#imagediv").empty(); // 或 .html('') + + // 2) 构造要插入的新内容:一个带 class 和 id 的 div,内嵌一张图片 + + // 统一斜杠并去除末尾可能的查询参数 + const newContent = ` + 图片预览 + `; + + // 3) 将新内容插入到目标 div 中 + $("#imagediv").html(newContent); + + + } + + function getImageByBagAndIndex(bagname,imageIndex,htmlname,bagfolder) { + + var imagename = ""; + // 统一斜杠并去除末尾可能的查询参数 + $.ajax({ + url: '/api/getimage', + async: false, // 同步 + method: 'GET', + data: { bagname: bagname, + imageIndex: imageIndex, + bagfolder: bagfolder, + htmlname:htmlname + }, + dataType: 'json', + success(res) { + var data = res.result + + console.log(data) + var size = data.size + imagename = data.imagename + } + }) + console.log("imagename===="+imagename) + return imagename; + } + + function getCentralQuerySession(id) { + var data ; + // 统一斜杠并去除末尾可能的查询参数 + $.ajax({ + url: `/api/items/${id}`, + async: false, // 同步 + method: 'GET', + data: { id: id + }, + dataType: 'json', + success(res) { + data = res.result + + + } + }) + console.log("data===="+data) + return data; + } + + function runMTACommand(command,html_name,central_session ,query_sessions,id,bag_folder,category) { + console.log('runMTACommand methon id value :'+id ) + var imagename = ""; + console.log(bag_folder) + // 统一斜杠并去除末尾可能的查询参数 + $.ajax({ + url: '/api/runmtacommand', + async: false, // 同步 + method: 'GET', + data: { command: command, + html_name: html_name, + central_session: central_session, + query_sessions:query_sessions, + id:id , + bag_folder:bag_folder, + category:category + }, + dataType: 'json', + success(res) { + console.log(res.status) + var checkResult = res.success + var msg = res.message + showResult(checkResult,msg) + + console.log(res) + var data = res.message + console.log(data) + + + + } + }) + + } + + + + function showResult(result ,msg) { + + if (result ){ + Swal.fire({ + title: '任务创建成功!', + text: msg, + icon: 'success' + }); + } else { + Swal.fire({ + title: '任务创建失败', + text: '任务创建失败,请联系管理员', + icon: 'error' + }); + } + } + +// 可选:用于确认脚本已加载 +console.log('[detail.js] loaded, getJpgFileName =', typeof getJpgFileName); diff --git a/fst_data_pipeline/apps/mta_manage_system/static/js/injector.js b/fst_data_pipeline/apps/mta_manage_system/static/js/injector.js new file mode 100644 index 0000000..9aff557 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/js/injector.js @@ -0,0 +1,73 @@ +(function () { + const send = (type, detail = {}) => { + try { + // 向父窗口发送事件;同源策略下父窗口可接收 + window.parent.postMessage({ type, timestamp: Date.now(), ...detail }, '*'); + } catch (e) { + console.warn('[injector] postMessage 失败:', e); + } + }; + + // 页面开始加载 + send('page-start', { href: location.href }); + + // 点击 + document.addEventListener('click', (e) => { + send('click', { + target: e.target.tagName.toLowerCase() + (e.target.id ? '#' + e.target.id : '') + (e.target.className ? '.' + e.target.className.split(' ').join('.') : ''), + href: location.href, + x: e.clientX, y: e.clientY, + button: e.button, buttons: e.buttons + }); + }, true); + + // 输入/变更(含输入框、文本域、选择框等) + ['input', 'change', 'focus', 'blur'].forEach(evtName => { + document.addEventListener(evtName, (e) => { + const tag = e.target.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') { + send(evtName, { + target: e.target.tagName.toLowerCase() + (e.target.id ? '#' + e.target.id : '') + '.' + e.target.className.split(' ').join('.'), + href: location.href, + value: e.target.value, + name: e.target.name + }); + } else { + send(evtName, { target: tag, href: location.href }); + } + }, true); + }); + + // 滚动 + let scrollTmr = null; + window.addEventListener('scroll', () => { + if (scrollTmr) clearTimeout(scrollTmr); + scrollTmr = setTimeout(() => { + send('scroll', { + href: location.href, + scrollTop: window.scrollY || document.documentElement.scrollTop, + scrollLeft: window.scrollX || document.documentElement.scrollLeft + }); + }, 200); + }, true); + + // 键盘(可观测快捷键等) + document.addEventListener('keydown', (e) => { + send('keydown', { + key: e.key, code: e.code, + ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey, + target: e.target.tagName.toLowerCase(), + href: location.href + }); + }, true); + + // 页面可见性变化(切换标签/最小化) + document.addEventListener('visibilitychange', () => { + send('visibilitychange', { hidden: document.hidden, href: location.href }); + }); + + // 页面卸载前 + window.addEventListener('beforeunload', () => { + send('page-leave', { href: location.href }); + }); +})(); \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/static/js/jquery-3.7.1.min.js b/fst_data_pipeline/apps/mta_manage_system/static/js/jquery-3.7.1.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/static/js/jquery-3.7.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0new Promise(t=>{if(!e)return t();const n=window.scrollX,i=window.scrollY;o.restoreFocusTimeout=setTimeout(()=>{o.previousActiveElement instanceof HTMLElement?(o.previousActiveElement.focus(),o.previousActiveElement=null):document.body&&document.body.focus(),t()},100),window.scrollTo(n,i)}),s="swal2-",r=["container","shown","height-auto","iosfix","popup","modal","no-backdrop","no-transition","toast","toast-shown","show","hide","close","title","html-container","actions","confirm","deny","cancel","footer","icon","icon-content","image","input","file","range","select","radio","checkbox","label","textarea","inputerror","input-label","validation-message","progress-steps","active-progress-step","progress-step","progress-step-line","loader","loading","styled","top","top-start","top-end","top-left","top-right","center","center-start","center-end","center-left","center-right","bottom","bottom-start","bottom-end","bottom-left","bottom-right","grow-row","grow-column","grow-fullscreen","rtl","timer-progress-bar","timer-progress-bar-container","scrollbar-measure","icon-success","icon-warning","icon-info","icon-question","icon-error","draggable","dragging"].reduce((e,t)=>(e[t]=s+t,e),{}),a=["success","warning","info","question","error"].reduce((e,t)=>(e[t]=s+t,e),{}),l="SweetAlert2:",c=e=>e.charAt(0).toUpperCase()+e.slice(1),u=e=>{console.warn(`${l} ${"object"==typeof e?e.join(" "):e}`)},d=e=>{console.error(`${l} ${e}`)},p=[],m=(e,t=null)=>{var n;n=`"${e}" is deprecated and will be removed in the next major release.${t?` Use "${t}" instead.`:""}`,p.includes(n)||(p.push(n),u(n))},h=e=>"function"==typeof e?e():e,g=e=>e&&"function"==typeof e.toPromise,f=e=>g(e)?e.toPromise():Promise.resolve(e),b=e=>e&&Promise.resolve(e)===e,y=()=>document.body.querySelector(`.${r.container}`),v=e=>{const t=y();return t?t.querySelector(e):null},w=e=>v(`.${e}`),C=()=>w(r.popup),A=()=>w(r.icon),E=()=>w(r.title),k=()=>w(r["html-container"]),B=()=>w(r.image),$=()=>w(r["progress-steps"]),L=()=>w(r["validation-message"]),P=()=>v(`.${r.actions} .${r.confirm}`),x=()=>v(`.${r.actions} .${r.cancel}`),T=()=>v(`.${r.actions} .${r.deny}`),S=()=>v(`.${r.loader}`),O=()=>w(r.actions),M=()=>w(r.footer),j=()=>w(r["timer-progress-bar"]),H=()=>w(r.close),I=()=>{const e=C();if(!e)return[];const t=e.querySelectorAll('[tabindex]:not([tabindex="-1"]):not([tabindex="0"])'),n=Array.from(t).sort((e,t)=>{const n=parseInt(e.getAttribute("tabindex")||"0"),o=parseInt(t.getAttribute("tabindex")||"0");return n>o?1:n"-1"!==e.getAttribute("tabindex"));return[...new Set(n.concat(i))].filter(e=>ee(e))},D=()=>N(document.body,r.shown)&&!N(document.body,r["toast-shown"])&&!N(document.body,r["no-backdrop"]),V=()=>{const e=C();return!!e&&N(e,r.toast)},q=(e,t)=>{if(e.textContent="",t){const n=(new DOMParser).parseFromString(t,"text/html"),o=n.querySelector("head");o&&Array.from(o.childNodes).forEach(t=>{e.appendChild(t)});const i=n.querySelector("body");i&&Array.from(i.childNodes).forEach(t=>{t instanceof HTMLVideoElement||t instanceof HTMLAudioElement?e.appendChild(t.cloneNode(!0)):e.appendChild(t)})}},N=(e,t)=>{if(!t)return!1;const n=t.split(/\s+/);for(let t=0;t{if(((e,t)=>{Array.from(e.classList).forEach(n=>{Object.values(r).includes(n)||Object.values(a).includes(n)||Object.values(t.showClass||{}).includes(n)||e.classList.remove(n)})})(e,t),!t.customClass)return;const o=t.customClass[n];o&&("string"==typeof o||o.forEach?z(e,o):u(`Invalid type of customClass.${n}! Expected string or iterable object, got "${typeof o}"`))},F=(e,t)=>{if(!t)return null;switch(t){case"select":case"textarea":case"file":return e.querySelector(`.${r.popup} > .${r[t]}`);case"checkbox":return e.querySelector(`.${r.popup} > .${r.checkbox} input`);case"radio":return e.querySelector(`.${r.popup} > .${r.radio} input:checked`)||e.querySelector(`.${r.popup} > .${r.radio} input:first-child`);case"range":return e.querySelector(`.${r.popup} > .${r.range} input`);default:return e.querySelector(`.${r.popup} > .${r.input}`)}},R=e=>{if(e.focus(),"file"!==e.type){const t=e.value;e.value="",e.value=t}},U=(e,t,n)=>{e&&t&&("string"==typeof t&&(t=t.split(/\s+/).filter(Boolean)),t.forEach(t=>{Array.isArray(e)?e.forEach(e=>{n?e.classList.add(t):e.classList.remove(t)}):n?e.classList.add(t):e.classList.remove(t)}))},z=(e,t)=>{U(e,t,!0)},W=(e,t)=>{U(e,t,!1)},K=(e,t)=>{const n=Array.from(e.children);for(let e=0;e{n===`${parseInt(`${n}`)}`&&(n=parseInt(n)),n||0===parseInt(`${n}`)?e.style.setProperty(t,"number"==typeof n?`${n}px`:n):e.style.removeProperty(t)},X=(e,t="flex")=>{e&&(e.style.display=t)},Z=e=>{e&&(e.style.display="none")},J=(e,t="block")=>{e&&new MutationObserver(()=>{Q(e,e.innerHTML,t)}).observe(e,{childList:!0,subtree:!0})},G=(e,t,n,o)=>{const i=e.querySelector(t);i&&i.style.setProperty(n,o)},Q=(e,t,n="flex")=>{t?X(e,n):Z(e)},ee=e=>!(!e||!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)),te=e=>!!(e.scrollHeight>e.clientHeight),ne=e=>{const t=window.getComputedStyle(e),n=parseFloat(t.getPropertyValue("animation-duration")||"0"),o=parseFloat(t.getPropertyValue("transition-duration")||"0");return n>0||o>0},oe=(e,t=!1)=>{const n=j();n&&ee(n)&&(t&&(n.style.transition="none",n.style.width="100%"),setTimeout(()=>{n.style.transition=`width ${e/1e3}s linear`,n.style.width="0%"},10))},ie=`\n
\n \n
    \n
    \n \n

    \n
    \n \n \n
    \n \n \n
    \n \n
    \n \n \n
    \n
    \n
    \n \n \n \n
    \n
    \n
    \n
    \n
    \n
    \n`.replace(/(^|\n)\s*/g,""),se=()=>{o.currentInstance.resetValidationMessage()},re=e=>{const t=(()=>{const e=y();return!!e&&(e.remove(),W([document.documentElement,document.body],[r["no-backdrop"],r["toast-shown"],r["has-column"]]),!0)})();if("undefined"==typeof window||"undefined"==typeof document)return void d("SweetAlert2 requires document to initialize");const n=document.createElement("div");n.className=r.container,t&&z(n,r["no-transition"]),q(n,ie),n.dataset.swal2Theme=e.theme;const o="string"==typeof(i=e.target)?document.querySelector(i):i;var i;o.appendChild(n),e.topLayer&&(n.setAttribute("popover",""),n.showPopover()),(e=>{const t=C();t.setAttribute("role",e.toast?"alert":"dialog"),t.setAttribute("aria-live",e.toast?"polite":"assertive"),e.toast||t.setAttribute("aria-modal","true")})(e),(e=>{"rtl"===window.getComputedStyle(e).direction&&z(y(),r.rtl)})(o),(()=>{const e=C(),t=K(e,r.input),n=K(e,r.file),o=e.querySelector(`.${r.range} input`),i=e.querySelector(`.${r.range} output`),s=K(e,r.select),a=e.querySelector(`.${r.checkbox} input`),l=K(e,r.textarea);t.oninput=se,n.onchange=se,s.onchange=se,a.onchange=se,l.oninput=se,o.oninput=()=>{se(),i.value=o.value},o.onchange=()=>{se(),i.value=o.value}})()},ae=(e,t)=>{e instanceof HTMLElement?t.appendChild(e):"object"==typeof e?le(e,t):e&&q(t,e)},le=(e,t)=>{e.jquery?ce(t,e):q(t,e.toString())},ce=(e,t)=>{if(e.textContent="",0 in t)for(let n=0;n in t;n++)e.appendChild(t[n].cloneNode(!0));else e.appendChild(t.cloneNode(!0))},ue=(e,t)=>{const n=O(),o=S();n&&o&&(t.showConfirmButton||t.showDenyButton||t.showCancelButton?X(n):Z(n),_(n,t,"actions"),function(e,t,n){const o=P(),i=T(),s=x();if(!o||!i||!s)return;pe(o,"confirm",n),pe(i,"deny",n),pe(s,"cancel",n),function(e,t,n,o){if(!o.buttonsStyling)return void W([e,t,n],r.styled);z([e,t,n],r.styled),o.confirmButtonColor&&e.style.setProperty("--swal2-confirm-button-background-color",o.confirmButtonColor);o.denyButtonColor&&t.style.setProperty("--swal2-deny-button-background-color",o.denyButtonColor);o.cancelButtonColor&&n.style.setProperty("--swal2-cancel-button-background-color",o.cancelButtonColor);de(e),de(t),de(n)}(o,i,s,n),n.reverseButtons&&(n.toast?(e.insertBefore(s,o),e.insertBefore(i,o)):(e.insertBefore(s,t),e.insertBefore(i,t),e.insertBefore(o,t)))}(n,o,t),q(o,t.loaderHtml||""),_(o,t,"loader"))};function de(e){const t=window.getComputedStyle(e);if(t.getPropertyValue("--swal2-action-button-focus-box-shadow"))return;const n=t.backgroundColor.replace(/rgba?\((\d+), (\d+), (\d+).*/,"rgba($1, $2, $3, 0.5)");e.style.setProperty("--swal2-action-button-focus-box-shadow",t.getPropertyValue("--swal2-outline").replace(/ rgba\(.*/,` ${n}`))}function pe(e,t,n){const o=c(t);Q(e,n[`show${o}Button`],"inline-block"),q(e,n[`${t}ButtonText`]||""),e.setAttribute("aria-label",n[`${t}ButtonAriaLabel`]||""),e.className=r[t],_(e,n,`${t}Button`)}const me=(e,t)=>{const n=y();n&&(!function(e,t){"string"==typeof t?e.style.background=t:t||z([document.documentElement,document.body],r["no-backdrop"])}(n,t.backdrop),function(e,t){if(!t)return;t in r?z(e,r[t]):(u('The "position" parameter is not valid, defaulting to "center"'),z(e,r.center))}(n,t.position),function(e,t){if(!t)return;z(e,r[`grow-${t}`])}(n,t.grow),_(n,t,"container"))};var he={innerParams:new WeakMap,domCache:new WeakMap};const ge=["input","file","range","select","radio","checkbox","textarea"],fe=e=>{if(!e.input)return;if(!Ee[e.input])return void d(`Unexpected type of input! Expected ${Object.keys(Ee).join(" | ")}, got "${e.input}"`);const t=Ce(e.input);if(!t)return;const n=Ee[e.input](t,e);X(t),e.inputAutoFocus&&setTimeout(()=>{R(n)})},be=(e,t)=>{const n=C();if(!n)return;const o=F(n,e);if(o){(e=>{for(let t=0;t{if(!e.input)return;const t=Ce(e.input);t&&_(t,e,"input")},ve=(e,t)=>{!e.placeholder&&t.inputPlaceholder&&(e.placeholder=t.inputPlaceholder)},we=(e,t,n)=>{if(n.inputLabel){const o=document.createElement("label"),i=r["input-label"];o.setAttribute("for",e.id),o.className=i,"object"==typeof n.customClass&&z(o,n.customClass.inputLabel),o.innerText=n.inputLabel,t.insertAdjacentElement("beforebegin",o)}},Ce=e=>{const t=C();if(t)return K(t,r[e]||r.input)},Ae=(e,t)=>{["string","number"].includes(typeof t)?e.value=`${t}`:b(t)||u(`Unexpected type of inputValue! Expected "string", "number" or "Promise", got "${typeof t}"`)},Ee={};Ee.text=Ee.email=Ee.password=Ee.number=Ee.tel=Ee.url=Ee.search=Ee.date=Ee["datetime-local"]=Ee.time=Ee.week=Ee.month=(e,t)=>(Ae(e,t.inputValue),we(e,e,t),ve(e,t),e.type=t.input,e),Ee.file=(e,t)=>(we(e,e,t),ve(e,t),e),Ee.range=(e,t)=>{const n=e.querySelector("input"),o=e.querySelector("output");return Ae(n,t.inputValue),n.type=t.input,Ae(o,t.inputValue),we(n,e,t),e},Ee.select=(e,t)=>{if(e.textContent="",t.inputPlaceholder){const n=document.createElement("option");q(n,t.inputPlaceholder),n.value="",n.disabled=!0,n.selected=!0,e.appendChild(n)}return we(e,e,t),e},Ee.radio=e=>(e.textContent="",e),Ee.checkbox=(e,t)=>{const n=F(C(),"checkbox");n.value="1",n.checked=Boolean(t.inputValue);const o=e.querySelector("span");return q(o,t.inputPlaceholder||t.inputLabel),n},Ee.textarea=(e,t)=>{Ae(e,t.inputValue),ve(e,t),we(e,e,t);return setTimeout(()=>{if("MutationObserver"in window){const n=parseInt(window.getComputedStyle(C()).width);new MutationObserver(()=>{if(!document.body.contains(e))return;const o=e.offsetWidth+(i=e,parseInt(window.getComputedStyle(i).marginLeft)+parseInt(window.getComputedStyle(i).marginRight));var i;o>n?C().style.width=`${o}px`:Y(C(),"width",t.width)}).observe(e,{attributes:!0,attributeFilter:["style"]})}}),e};const ke=(e,t)=>{const n=k();n&&(J(n),_(n,t,"htmlContainer"),t.html?(ae(t.html,n),X(n,"block")):t.text?(n.textContent=t.text,X(n,"block")):Z(n),((e,t)=>{const n=C();if(!n)return;const o=he.innerParams.get(e),i=!o||t.input!==o.input;ge.forEach(e=>{const o=K(n,r[e]);o&&(be(e,t.inputAttributes),o.className=r[e],i&&Z(o))}),t.input&&(i&&fe(t),ye(t))})(e,t))},Be=(e,t)=>{for(const[n,o]of Object.entries(a))t.icon!==n&&W(e,o);z(e,t.icon&&a[t.icon]),Pe(e,t),$e(),_(e,t,"icon")},$e=()=>{const e=C();if(!e)return;const t=window.getComputedStyle(e).getPropertyValue("background-color"),n=e.querySelectorAll("[class^=swal2-success-circular-line], .swal2-success-fix");for(let e=0;e{if(!t.icon&&!t.iconHtml)return;let n=e.innerHTML,o="";if(t.iconHtml)o=xe(t.iconHtml);else if("success"===t.icon)o=(e=>`\n ${e.animation?'
    ':""}\n \n
    \n ${e.animation?'
    ':""}\n ${e.animation?'
    ':""}\n`)(t),n=n.replace(/ style=".*?"/g,"");else if("error"===t.icon)o='\n \n \n \n \n';else if(t.icon){o=xe({question:"?",warning:"!",info:"i"}[t.icon])}n.trim()!==o.trim()&&q(e,o)},Pe=(e,t)=>{if(t.iconColor){e.style.color=t.iconColor,e.style.borderColor=t.iconColor;for(const n of[".swal2-success-line-tip",".swal2-success-line-long",".swal2-x-mark-line-left",".swal2-x-mark-line-right"])G(e,n,"background-color",t.iconColor);G(e,".swal2-success-ring","border-color",t.iconColor)}},xe=e=>`
    ${e}
    `;let Te=!1,Se=0,Oe=0,Me=0,je=0;const He=e=>{const t=C();if(e.target===t||A().contains(e.target)){Te=!0;const n=Ve(e);Se=n.clientX,Oe=n.clientY,Me=parseInt(t.style.insetInlineStart)||0,je=parseInt(t.style.insetBlockStart)||0,z(t,"swal2-dragging")}},Ie=e=>{const t=C();if(Te){let{clientX:n,clientY:o}=Ve(e);t.style.insetInlineStart=`${Me+(n-Se)}px`,t.style.insetBlockStart=`${je+(o-Oe)}px`}},De=()=>{const e=C();Te=!1,W(e,"swal2-dragging")},Ve=e=>{let t=0,n=0;return e.type.startsWith("mouse")?(t=e.clientX,n=e.clientY):e.type.startsWith("touch")&&(t=e.touches[0].clientX,n=e.touches[0].clientY),{clientX:t,clientY:n}},qe=(e,t)=>{const n=y(),o=C();if(n&&o){if(t.toast){Y(n,"width",t.width),o.style.width="100%";const e=S();e&&o.insertBefore(e,A())}else Y(o,"width",t.width);Y(o,"padding",t.padding),t.color&&(o.style.color=t.color),t.background&&(o.style.background=t.background),Z(L()),Ne(o,t),t.draggable&&!t.toast?(z(o,r.draggable),(e=>{e.addEventListener("mousedown",He),document.body.addEventListener("mousemove",Ie),e.addEventListener("mouseup",De),e.addEventListener("touchstart",He),document.body.addEventListener("touchmove",Ie),e.addEventListener("touchend",De)})(o)):(W(o,r.draggable),(e=>{e.removeEventListener("mousedown",He),document.body.removeEventListener("mousemove",Ie),e.removeEventListener("mouseup",De),e.removeEventListener("touchstart",He),document.body.removeEventListener("touchmove",Ie),e.removeEventListener("touchend",De)})(o))}},Ne=(e,t)=>{const n=t.showClass||{};e.className=`${r.popup} ${ee(e)?n.popup:""}`,t.toast?(z([document.documentElement,document.body],r["toast-shown"]),z(e,r.toast)):z(e,r.modal),_(e,t,"popup"),"string"==typeof t.customClass&&z(e,t.customClass),t.icon&&z(e,r[`icon-${t.icon}`])},_e=e=>{const t=document.createElement("li");return z(t,r["progress-step"]),q(t,e),t},Fe=e=>{const t=document.createElement("li");return z(t,r["progress-step-line"]),e.progressStepsDistance&&Y(t,"width",e.progressStepsDistance),t},Re=(e,t)=>{qe(0,t),me(0,t),((e,t)=>{const n=$();if(!n)return;const{progressSteps:o,currentProgressStep:i}=t;o&&0!==o.length&&void 0!==i?(X(n),n.textContent="",i>=o.length&&u("Invalid currentProgressStep parameter, it should be less than progressSteps.length (currentProgressStep like JS arrays starts from 0)"),o.forEach((e,s)=>{const a=_e(e);if(n.appendChild(a),s===i&&z(a,r["active-progress-step"]),s!==o.length-1){const e=Fe(t);n.appendChild(e)}})):Z(n)})(0,t),((e,t)=>{const n=he.innerParams.get(e),o=A();if(!o)return;if(n&&t.icon===n.icon)return Le(o,t),void Be(o,t);if(!t.icon&&!t.iconHtml)return void Z(o);if(t.icon&&-1===Object.keys(a).indexOf(t.icon))return d(`Unknown icon! Expected "success", "error", "warning", "info" or "question", got "${t.icon}"`),void Z(o);X(o),Le(o,t),Be(o,t),z(o,t.showClass&&t.showClass.icon),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",$e)})(e,t),((e,t)=>{const n=B();n&&(t.imageUrl?(X(n,""),n.setAttribute("src",t.imageUrl),n.setAttribute("alt",t.imageAlt||""),Y(n,"width",t.imageWidth),Y(n,"height",t.imageHeight),n.className=r.image,_(n,t,"image")):Z(n))})(0,t),((e,t)=>{const n=E();n&&(J(n),Q(n,Boolean(t.title||t.titleText),"block"),t.title&&ae(t.title,n),t.titleText&&(n.innerText=t.titleText),_(n,t,"title"))})(0,t),((e,t)=>{const n=H();n&&(q(n,t.closeButtonHtml||""),_(n,t,"closeButton"),Q(n,t.showCloseButton),n.setAttribute("aria-label",t.closeButtonAriaLabel||""))})(0,t),ke(e,t),ue(0,t),((e,t)=>{const n=M();n&&(J(n),Q(n,Boolean(t.footer),"block"),t.footer&&ae(t.footer,n),_(n,t,"footer"))})(0,t);const n=C();"function"==typeof t.didRender&&n&&t.didRender(n),o.eventEmitter.emit("didRender",n)},Ue=()=>{var e;return null===(e=P())||void 0===e?void 0:e.click()},ze=Object.freeze({cancel:"cancel",backdrop:"backdrop",close:"close",esc:"esc",timer:"timer"}),We=e=>{e.keydownTarget&&e.keydownHandlerAdded&&(e.keydownTarget.removeEventListener("keydown",e.keydownHandler,{capture:e.keydownListenerCapture}),e.keydownHandlerAdded=!1)},Ke=(e,t)=>{var n;const o=I();if(o.length)return-2===(e+=t)&&(e=o.length-1),e===o.length?e=0:-1===e&&(e=o.length-1),void o[e].focus();null===(n=C())||void 0===n||n.focus()},Ye=["ArrowRight","ArrowDown"],Xe=["ArrowLeft","ArrowUp"],Ze=(e,t,n)=>{e&&(t.isComposing||229===t.keyCode||(e.stopKeydownPropagation&&t.stopPropagation(),"Enter"===t.key?Je(t,e):"Tab"===t.key?Ge(t):[...Ye,...Xe].includes(t.key)?Qe(t.key):"Escape"===t.key&&et(t,e,n)))},Je=(e,t)=>{if(!h(t.allowEnterKey))return;const n=F(C(),t.input);if(e.target&&n&&e.target instanceof HTMLElement&&e.target.outerHTML===n.outerHTML){if(["textarea","file"].includes(t.input))return;Ue(),e.preventDefault()}},Ge=e=>{const t=e.target,n=I();let o=-1;for(let e=0;e{const t=O(),n=P(),o=T(),i=x();if(!(t&&n&&o&&i))return;const s=[n,o,i];if(document.activeElement instanceof HTMLElement&&!s.includes(document.activeElement))return;const r=Ye.includes(e)?"nextElementSibling":"previousElementSibling";let a=document.activeElement;if(a){for(let e=0;e{e.preventDefault(),h(t.allowEscapeKey)&&n(ze.esc)};var tt={swalPromiseResolve:new WeakMap,swalPromiseReject:new WeakMap};const nt=()=>{Array.from(document.body.children).forEach(e=>{e.hasAttribute("data-previous-aria-hidden")?(e.setAttribute("aria-hidden",e.getAttribute("data-previous-aria-hidden")||""),e.removeAttribute("data-previous-aria-hidden")):e.removeAttribute("aria-hidden")})},ot="undefined"!=typeof window&&!!window.GestureEvent,it=()=>{const e=y();if(!e)return;let t;e.ontouchstart=e=>{t=st(e)},e.ontouchmove=e=>{t&&(e.preventDefault(),e.stopPropagation())}},st=e=>{const t=e.target,n=y(),o=k();return!(!n||!o)&&(!rt(e)&&!at(e)&&(t===n||!(te(n)||!(t instanceof HTMLElement)||((e,t)=>{let n=e;for(;n&&n!==t;){if(te(n))return!0;n=n.parentElement}return!1})(t,o)||"INPUT"===t.tagName||"TEXTAREA"===t.tagName||te(o)&&o.contains(t))))},rt=e=>e.touches&&e.touches.length&&"stylus"===e.touches[0].touchType,at=e=>e.touches&&e.touches.length>1;let lt=null;const ct=e=>{null===lt&&(document.body.scrollHeight>window.innerHeight||"scroll"===e)&&(lt=parseInt(window.getComputedStyle(document.body).getPropertyValue("padding-right")),document.body.style.paddingRight=`${lt+(()=>{const e=document.createElement("div");e.className=r["scrollbar-measure"],document.body.appendChild(e);const t=e.getBoundingClientRect().width-e.clientWidth;return document.body.removeChild(e),t})()}px`)};function ut(e,t,n,s){V()?yt(e,s):(i(n).then(()=>yt(e,s)),We(o)),ot?(t.setAttribute("style","display:none !important"),t.removeAttribute("class"),t.innerHTML=""):t.remove(),D()&&(null!==lt&&(document.body.style.paddingRight=`${lt}px`,lt=null),(()=>{if(N(document.body,r.iosfix)){const e=parseInt(document.body.style.top,10);W(document.body,r.iosfix),document.body.style.top="",document.body.scrollTop=-1*e}})(),nt()),W([document.documentElement,document.body],[r.shown,r["height-auto"],r["no-backdrop"],r["toast-shown"]])}function dt(e){e=gt(e);const t=tt.swalPromiseResolve.get(this),n=pt(this);this.isAwaitingPromise?e.isDismissed||(ht(this),t(e)):n&&t(e)}const pt=e=>{const t=C();if(!t)return!1;const n=he.innerParams.get(e);if(!n||N(t,n.hideClass.popup))return!1;W(t,n.showClass.popup),z(t,n.hideClass.popup);const o=y();return W(o,n.showClass.backdrop),z(o,n.hideClass.backdrop),ft(e,t,n),!0};function mt(e){const t=tt.swalPromiseReject.get(this);ht(this),t&&t(e)}const ht=e=>{e.isAwaitingPromise&&(delete e.isAwaitingPromise,he.innerParams.get(e)||e._destroy())},gt=e=>void 0===e?{isConfirmed:!1,isDenied:!1,isDismissed:!0}:Object.assign({isConfirmed:!1,isDenied:!1,isDismissed:!1},e),ft=(e,t,n)=>{var i;const s=y(),r=ne(t);"function"==typeof n.willClose&&n.willClose(t),null===(i=o.eventEmitter)||void 0===i||i.emit("willClose",t),r?bt(e,t,s,n.returnFocus,n.didClose):ut(e,s,n.returnFocus,n.didClose)},bt=(e,t,n,i,s)=>{o.swalCloseEventFinishedCallback=ut.bind(null,e,n,i,s);const r=function(e){var n;e.target===t&&(null===(n=o.swalCloseEventFinishedCallback)||void 0===n||n.call(o),delete o.swalCloseEventFinishedCallback,t.removeEventListener("animationend",r),t.removeEventListener("transitionend",r))};t.addEventListener("animationend",r),t.addEventListener("transitionend",r)},yt=(e,t)=>{setTimeout(()=>{var n;"function"==typeof t&&t.bind(e.params)(),null===(n=o.eventEmitter)||void 0===n||n.emit("didClose"),e._destroy&&e._destroy()})},vt=e=>{let t=C();if(t||new Qn,t=C(),!t)return;const n=S();V()?Z(A()):wt(t,e),X(n),t.setAttribute("data-loading","true"),t.setAttribute("aria-busy","true"),t.focus()},wt=(e,t)=>{const n=O(),o=S();n&&o&&(!t&&ee(P())&&(t=P()),X(n),t&&(Z(t),o.setAttribute("data-button-to-replace",t.className),n.insertBefore(o,t)),z([e,n],r.loading))},Ct=e=>e.checked?1:0,At=e=>e.checked?e.value:null,Et=e=>e.files&&e.files.length?null!==e.getAttribute("multiple")?e.files:e.files[0]:null,kt=(e,t)=>{const n=C();if(!n)return;const o=e=>{"select"===t.input?function(e,t,n){const o=K(e,r.select);if(!o)return;const i=(e,t,o)=>{const i=document.createElement("option");i.value=o,q(i,t),i.selected=Lt(o,n.inputValue),e.appendChild(i)};t.forEach(e=>{const t=e[0],n=e[1];if(Array.isArray(n)){const e=document.createElement("optgroup");e.label=t,e.disabled=!1,o.appendChild(e),n.forEach(t=>i(e,t[1],t[0]))}else i(o,n,t)}),o.focus()}(n,$t(e),t):"radio"===t.input&&function(e,t,n){const o=K(e,r.radio);if(!o)return;t.forEach(e=>{const t=e[0],i=e[1],s=document.createElement("input"),a=document.createElement("label");s.type="radio",s.name=r.radio,s.value=t,Lt(t,n.inputValue)&&(s.checked=!0);const l=document.createElement("span");q(l,i),l.className=r.label,a.appendChild(s),a.appendChild(l),o.appendChild(a)});const i=o.querySelectorAll("input");i.length&&i[0].focus()}(n,$t(e),t)};g(t.inputOptions)||b(t.inputOptions)?(vt(P()),f(t.inputOptions).then(t=>{e.hideLoading(),o(t)})):"object"==typeof t.inputOptions?o(t.inputOptions):d("Unexpected type of inputOptions! Expected object, Map or Promise, got "+typeof t.inputOptions)},Bt=(e,t)=>{const n=e.getInput();n&&(Z(n),f(t.inputValue).then(o=>{n.value="number"===t.input?`${parseFloat(o)||0}`:`${o}`,X(n),n.focus(),e.hideLoading()}).catch(t=>{d(`Error in inputValue promise: ${t}`),n.value="",X(n),n.focus(),e.hideLoading()}))};const $t=e=>{const t=[];return e instanceof Map?e.forEach((e,n)=>{let o=e;"object"==typeof o&&(o=$t(o)),t.push([n,o])}):Object.keys(e).forEach(n=>{let o=e[n];"object"==typeof o&&(o=$t(o)),t.push([n,o])}),t},Lt=(e,t)=>!!t&&t.toString()===e.toString(),Pt=(e,t)=>{const n=he.innerParams.get(e);if(!n.input)return void d(`The "input" parameter is needed to be set when using returnInputValueOn${c(t)}`);const o=e.getInput(),i=((e,t)=>{const n=e.getInput();if(!n)return null;switch(t.input){case"checkbox":return Ct(n);case"radio":return At(n);case"file":return Et(n);default:return t.inputAutoTrim?n.value.trim():n.value}})(e,n);n.inputValidator?xt(e,i,t):o&&!o.checkValidity()?(e.enableButtons(),e.showValidationMessage(n.validationMessage||o.validationMessage)):"deny"===t?Tt(e,i):Mt(e,i)},xt=(e,t,n)=>{const o=he.innerParams.get(e);e.disableInput();Promise.resolve().then(()=>f(o.inputValidator(t,o.validationMessage))).then(o=>{e.enableButtons(),e.enableInput(),o?e.showValidationMessage(o):"deny"===n?Tt(e,t):Mt(e,t)})},Tt=(e,t)=>{const n=he.innerParams.get(e||void 0);if(n.showLoaderOnDeny&&vt(T()),n.preDeny){e.isAwaitingPromise=!0;Promise.resolve().then(()=>f(n.preDeny(t,n.validationMessage))).then(n=>{!1===n?(e.hideLoading(),ht(e)):e.close({isDenied:!0,value:void 0===n?t:n})}).catch(t=>Ot(e||void 0,t))}else e.close({isDenied:!0,value:t})},St=(e,t)=>{e.close({isConfirmed:!0,value:t})},Ot=(e,t)=>{e.rejectPromise(t)},Mt=(e,t)=>{const n=he.innerParams.get(e||void 0);if(n.showLoaderOnConfirm&&vt(),n.preConfirm){e.resetValidationMessage(),e.isAwaitingPromise=!0;Promise.resolve().then(()=>f(n.preConfirm(t,n.validationMessage))).then(n=>{ee(L())||!1===n?(e.hideLoading(),ht(e)):St(e,void 0===n?t:n)}).catch(t=>Ot(e||void 0,t))}else St(e,t)};function jt(){const e=he.innerParams.get(this);if(!e)return;const t=he.domCache.get(this);Z(t.loader),V()?e.icon&&X(A()):Ht(t),W([t.popup,t.actions],r.loading),t.popup.removeAttribute("aria-busy"),t.popup.removeAttribute("data-loading"),t.confirmButton.disabled=!1,t.denyButton.disabled=!1,t.cancelButton.disabled=!1}const Ht=e=>{const t=e.popup.getElementsByClassName(e.loader.getAttribute("data-button-to-replace"));t.length?X(t[0],"inline-block"):ee(P())||ee(T())||ee(x())||Z(e.actions)};function It(){const e=he.innerParams.get(this),t=he.domCache.get(this);return t?F(t.popup,e.input):null}function Dt(e,t,n){const o=he.domCache.get(e);t.forEach(e=>{o[e].disabled=n})}function Vt(e,t){const n=C();if(n&&e)if("radio"===e.type){const e=n.querySelectorAll(`[name="${r.radio}"]`);for(let n=0;nObject.prototype.hasOwnProperty.call(zt,e),Zt=e=>-1!==Wt.indexOf(e),Jt=e=>Kt[e],Gt=e=>{Xt(e)||u(`Unknown parameter "${e}"`)},Qt=e=>{Yt.includes(e)&&u(`The parameter "${e}" is incompatible with toasts`)},en=e=>{const t=Jt(e);t&&m(e,t)},tn=e=>{!1===e.backdrop&&e.allowOutsideClick&&u('"allowOutsideClick" parameter requires `backdrop` parameter to be set to `true`'),e.theme&&!["light","dark","auto","minimal","borderless","bootstrap-4","bootstrap-4-light","bootstrap-4-dark","bootstrap-5","bootstrap-5-light","bootstrap-5-dark","material-ui","material-ui-light","material-ui-dark","embed-iframe","bulma","bulma-light","bulma-dark"].includes(e.theme)&&u(`Invalid theme "${e.theme}"`);for(const t in e)Gt(t),e.toast&&Qt(t),en(t)};function nn(e){const t=y(),n=C(),o=he.innerParams.get(this);if(!n||N(n,o.hideClass.popup))return void u("You're trying to update the closed or closing popup, that won't work. Use the update() method in preConfirm parameter or show a new popup.");const i=on(e),s=Object.assign({},o,i);tn(s),t.dataset.swal2Theme=s.theme,Re(this,s),he.innerParams.set(this,s),Object.defineProperties(this,{params:{value:Object.assign({},this.params,e),writable:!1,enumerable:!0}})}const on=e=>{const t={};return Object.keys(e).forEach(n=>{Zt(n)?t[n]=e[n]:u(`Invalid parameter to update: ${n}`)}),t};function sn(){const e=he.domCache.get(this),t=he.innerParams.get(this);t?(e.popup&&o.swalCloseEventFinishedCallback&&(o.swalCloseEventFinishedCallback(),delete o.swalCloseEventFinishedCallback),"function"==typeof t.didDestroy&&t.didDestroy(),o.eventEmitter.emit("didDestroy"),rn(this)):an(this)}const rn=e=>{an(e),delete e.params,delete o.keydownHandler,delete o.keydownTarget,delete o.currentInstance},an=e=>{e.isAwaitingPromise?(ln(he,e),e.isAwaitingPromise=!0):(ln(tt,e),ln(he,e),delete e.isAwaitingPromise,delete e.disableButtons,delete e.enableButtons,delete e.getInput,delete e.disableInput,delete e.enableInput,delete e.hideLoading,delete e.disableLoading,delete e.showValidationMessage,delete e.resetValidationMessage,delete e.close,delete e.closePopup,delete e.closeModal,delete e.closeToast,delete e.rejectPromise,delete e.update,delete e._destroy)},ln=(e,t)=>{for(const n in e)e[n].delete(t)};var cn=Object.freeze({__proto__:null,_destroy:sn,close:dt,closeModal:dt,closePopup:dt,closeToast:dt,disableButtons:Nt,disableInput:Ft,disableLoading:jt,enableButtons:qt,enableInput:_t,getInput:It,handleAwaitingPromise:ht,hideLoading:jt,rejectPromise:mt,resetValidationMessage:Ut,showValidationMessage:Rt,update:nn});const un=(e,t,n)=>{t.popup.onclick=()=>{e&&(dn(e)||e.timer||e.input)||n(ze.close)}},dn=e=>!!(e.showConfirmButton||e.showDenyButton||e.showCancelButton||e.showCloseButton);let pn=!1;const mn=e=>{e.popup.onmousedown=()=>{e.container.onmouseup=function(t){e.container.onmouseup=()=>{},t.target===e.container&&(pn=!0)}}},hn=e=>{e.container.onmousedown=t=>{t.target===e.container&&t.preventDefault(),e.popup.onmouseup=function(t){e.popup.onmouseup=()=>{},(t.target===e.popup||t.target instanceof HTMLElement&&e.popup.contains(t.target))&&(pn=!0)}}},gn=(e,t,n)=>{t.container.onclick=o=>{pn?pn=!1:o.target===t.container&&h(e.allowOutsideClick)&&n(ze.backdrop)}},fn=e=>e instanceof Element||(e=>"object"==typeof e&&e.jquery)(e);const bn=()=>{if(o.timeout)return(()=>{const e=j();if(!e)return;const t=parseInt(window.getComputedStyle(e).width);e.style.removeProperty("transition"),e.style.width="100%";const n=t/parseInt(window.getComputedStyle(e).width)*100;e.style.width=`${n}%`})(),o.timeout.stop()},yn=()=>{if(o.timeout){const e=o.timeout.start();return oe(e),e}};let vn=!1;const wn={};const Cn=e=>{for(let t=e.target;t&&t!==document;t=t.parentNode)for(const e in wn){const n=t.getAttribute(e);if(n)return void wn[e].fire({template:n})}};o.eventEmitter=new class{constructor(){this.events={}}_getHandlersByEventName(e){return void 0===this.events[e]&&(this.events[e]=[]),this.events[e]}on(e,t){const n=this._getHandlersByEventName(e);n.includes(t)||n.push(t)}once(e,t){const n=(...o)=>{this.removeListener(e,n),t.apply(this,o)};this.on(e,n)}emit(e,...t){this._getHandlersByEventName(e).forEach(e=>{try{e.apply(this,t)}catch(e){console.error(e)}})}removeListener(e,t){const n=this._getHandlersByEventName(e),o=n.indexOf(t);o>-1&&n.splice(o,1)}removeAllListeners(e){void 0!==this.events[e]&&(this.events[e].length=0)}reset(){this.events={}}};var An=Object.freeze({__proto__:null,argsToParams:e=>{const t={};return"object"!=typeof e[0]||fn(e[0])?["title","html","icon"].forEach((n,o)=>{const i=e[o];"string"==typeof i||fn(i)?t[n]=i:void 0!==i&&d(`Unexpected type of ${n}! Expected "string" or "Element", got ${typeof i}`)}):Object.assign(t,e[0]),t},bindClickHandler:function(e="data-swal-template"){wn[e]=this,vn||(document.body.addEventListener("click",Cn),vn=!0)},clickCancel:()=>{var e;return null===(e=x())||void 0===e?void 0:e.click()},clickConfirm:Ue,clickDeny:()=>{var e;return null===(e=T())||void 0===e?void 0:e.click()},enableLoading:vt,fire:function(...e){return new this(...e)},getActions:O,getCancelButton:x,getCloseButton:H,getConfirmButton:P,getContainer:y,getDenyButton:T,getFocusableElements:I,getFooter:M,getHtmlContainer:k,getIcon:A,getIconContent:()=>w(r["icon-content"]),getImage:B,getInputLabel:()=>w(r["input-label"]),getLoader:S,getPopup:C,getProgressSteps:$,getTimerLeft:()=>o.timeout&&o.timeout.getTimerLeft(),getTimerProgressBar:j,getTitle:E,getValidationMessage:L,increaseTimer:e=>{if(o.timeout){const t=o.timeout.increase(e);return oe(t,!0),t}},isDeprecatedParameter:Jt,isLoading:()=>{const e=C();return!!e&&e.hasAttribute("data-loading")},isTimerRunning:()=>!(!o.timeout||!o.timeout.isRunning()),isUpdatableParameter:Zt,isValidParameter:Xt,isVisible:()=>ee(C()),mixin:function(e){return class extends(this){_main(t,n){return super._main(t,Object.assign({},e,n))}}},off:(e,t)=>{e?t?o.eventEmitter.removeListener(e,t):o.eventEmitter.removeAllListeners(e):o.eventEmitter.reset()},on:(e,t)=>{o.eventEmitter.on(e,t)},once:(e,t)=>{o.eventEmitter.once(e,t)},resumeTimer:yn,showLoading:vt,stopTimer:bn,toggleTimer:()=>{const e=o.timeout;return e&&(e.running?bn():yn())}});class En{constructor(e,t){this.callback=e,this.remaining=t,this.running=!1,this.start()}start(){return this.running||(this.running=!0,this.started=new Date,this.id=setTimeout(this.callback,this.remaining)),this.remaining}stop(){return this.started&&this.running&&(this.running=!1,clearTimeout(this.id),this.remaining-=(new Date).getTime()-this.started.getTime()),this.remaining}increase(e){const t=this.running;return t&&this.stop(),this.remaining+=e,t&&this.start(),this.remaining}getTimerLeft(){return this.running&&(this.stop(),this.start()),this.remaining}isRunning(){return this.running}}const kn=["swal-title","swal-html","swal-footer"],Bn=e=>{const t={};return Array.from(e.querySelectorAll("swal-param")).forEach(e=>{Mn(e,["name","value"]);const n=e.getAttribute("name"),o=e.getAttribute("value");n&&o&&(t[n]="boolean"==typeof zt[n]?"false"!==o:"object"==typeof zt[n]?JSON.parse(o):o)}),t},$n=e=>{const t={};return Array.from(e.querySelectorAll("swal-function-param")).forEach(e=>{const n=e.getAttribute("name"),o=e.getAttribute("value");n&&o&&(t[n]=new Function(`return ${o}`)())}),t},Ln=e=>{const t={};return Array.from(e.querySelectorAll("swal-button")).forEach(e=>{Mn(e,["type","color","aria-label"]);const n=e.getAttribute("type");n&&["confirm","cancel","deny"].includes(n)&&(t[`${n}ButtonText`]=e.innerHTML,t[`show${c(n)}Button`]=!0,e.hasAttribute("color")&&(t[`${n}ButtonColor`]=e.getAttribute("color")),e.hasAttribute("aria-label")&&(t[`${n}ButtonAriaLabel`]=e.getAttribute("aria-label")))}),t},Pn=e=>{const t={},n=e.querySelector("swal-image");return n&&(Mn(n,["src","width","height","alt"]),n.hasAttribute("src")&&(t.imageUrl=n.getAttribute("src")||void 0),n.hasAttribute("width")&&(t.imageWidth=n.getAttribute("width")||void 0),n.hasAttribute("height")&&(t.imageHeight=n.getAttribute("height")||void 0),n.hasAttribute("alt")&&(t.imageAlt=n.getAttribute("alt")||void 0)),t},xn=e=>{const t={},n=e.querySelector("swal-icon");return n&&(Mn(n,["type","color"]),n.hasAttribute("type")&&(t.icon=n.getAttribute("type")),n.hasAttribute("color")&&(t.iconColor=n.getAttribute("color")),t.iconHtml=n.innerHTML),t},Tn=e=>{const t={},n=e.querySelector("swal-input");n&&(Mn(n,["type","label","placeholder","value"]),t.input=n.getAttribute("type")||"text",n.hasAttribute("label")&&(t.inputLabel=n.getAttribute("label")),n.hasAttribute("placeholder")&&(t.inputPlaceholder=n.getAttribute("placeholder")),n.hasAttribute("value")&&(t.inputValue=n.getAttribute("value")));const o=Array.from(e.querySelectorAll("swal-input-option"));return o.length&&(t.inputOptions={},o.forEach(e=>{Mn(e,["value"]);const n=e.getAttribute("value");if(!n)return;const o=e.innerHTML;t.inputOptions[n]=o})),t},Sn=(e,t)=>{const n={};for(const o in t){const i=t[o],s=e.querySelector(i);s&&(Mn(s,[]),n[i.replace(/^swal-/,"")]=s.innerHTML.trim())}return n},On=e=>{const t=kn.concat(["swal-param","swal-function-param","swal-button","swal-image","swal-icon","swal-input","swal-input-option"]);Array.from(e.children).forEach(e=>{const n=e.tagName.toLowerCase();t.includes(n)||u(`Unrecognized element <${n}>`)})},Mn=(e,t)=>{Array.from(e.attributes).forEach(n=>{-1===t.indexOf(n.name)&&u([`Unrecognized attribute "${n.name}" on <${e.tagName.toLowerCase()}>.`,""+(t.length?`Allowed attributes are: ${t.join(", ")}`:"To set the value, use HTML within the element.")])})},jn=e=>{const t=y(),n=C();"function"==typeof e.willOpen&&e.willOpen(n),o.eventEmitter.emit("willOpen",n);const i=window.getComputedStyle(document.body).overflowY;Vn(t,n,e),setTimeout(()=>{In(t,n)},10),D()&&(Dn(t,e.scrollbarPadding,i),(()=>{const e=y();Array.from(document.body.children).forEach(t=>{t.contains(e)||(t.hasAttribute("aria-hidden")&&t.setAttribute("data-previous-aria-hidden",t.getAttribute("aria-hidden")||""),t.setAttribute("aria-hidden","true"))})})()),V()||o.previousActiveElement||(o.previousActiveElement=document.activeElement),"function"==typeof e.didOpen&&setTimeout(()=>e.didOpen(n)),o.eventEmitter.emit("didOpen",n)},Hn=e=>{const t=C();if(e.target!==t)return;const n=y();t.removeEventListener("animationend",Hn),t.removeEventListener("transitionend",Hn),n.style.overflowY="auto",W(n,r["no-transition"])},In=(e,t)=>{ne(t)?(e.style.overflowY="hidden",t.addEventListener("animationend",Hn),t.addEventListener("transitionend",Hn)):e.style.overflowY="auto"},Dn=(e,t,n)=>{(()=>{if(ot&&!N(document.body,r.iosfix)){const e=document.body.scrollTop;document.body.style.top=-1*e+"px",z(document.body,r.iosfix),it()}})(),t&&"hidden"!==n&&ct(n),setTimeout(()=>{e.scrollTop=0})},Vn=(e,t,n)=>{z(e,n.showClass.backdrop),n.animation?(t.style.setProperty("opacity","0","important"),X(t,"grid"),setTimeout(()=>{z(t,n.showClass.popup),t.style.removeProperty("opacity")},10)):X(t,"grid"),z([document.documentElement,document.body],r.shown),n.heightAuto&&n.backdrop&&!n.toast&&z([document.documentElement,document.body],r["height-auto"])};var qn=(e,t)=>/^[a-zA-Z0-9.+_'-]+@[a-zA-Z0-9.-]+\.[a-zA-Z0-9-]+$/.test(e)?Promise.resolve():Promise.resolve(t||"Invalid email address"),Nn=(e,t)=>/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,63}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)$/.test(e)?Promise.resolve():Promise.resolve(t||"Invalid URL");function _n(e){!function(e){e.inputValidator||("email"===e.input&&(e.inputValidator=qn),"url"===e.input&&(e.inputValidator=Nn))}(e),e.showLoaderOnConfirm&&!e.preConfirm&&u("showLoaderOnConfirm is set to true, but preConfirm is not defined.\nshowLoaderOnConfirm should be used together with preConfirm, see usage example:\nhttps://sweetalert2.github.io/#ajax-request"),function(e){(!e.target||"string"==typeof e.target&&!document.querySelector(e.target)||"string"!=typeof e.target&&!e.target.appendChild)&&(u('Target parameter is not valid, defaulting to "body"'),e.target="body")}(e),"string"==typeof e.title&&(e.title=e.title.split("\n").join("
    ")),re(e)}let Fn;var Rn=new WeakMap;class Un{constructor(...t){if(n(this,Rn,void 0),"undefined"==typeof window)return;Fn=this;const o=Object.freeze(this.constructor.argsToParams(t));var i,s,r;this.params=o,this.isAwaitingPromise=!1,i=Rn,s=this,r=this._main(Fn.params),i.set(e(i,s),r)}_main(e,t={}){if(tn(Object.assign({},t,e)),o.currentInstance){const e=tt.swalPromiseResolve.get(o.currentInstance),{isAwaitingPromise:t}=o.currentInstance;o.currentInstance._destroy(),t||e({isDismissed:!0}),D()&&nt()}o.currentInstance=Fn;const n=Wn(e,t);_n(n),Object.freeze(n),o.timeout&&(o.timeout.stop(),delete o.timeout),clearTimeout(o.restoreFocusTimeout);const i=Kn(Fn);return Re(Fn,n),he.innerParams.set(Fn,n),zn(Fn,i,n)}then(e){return t(Rn,this).then(e)}finally(e){return t(Rn,this).finally(e)}}const zn=(e,t,n)=>new Promise((i,s)=>{const r=t=>{e.close({isDismissed:!0,dismiss:t,isConfirmed:!1,isDenied:!1})};tt.swalPromiseResolve.set(e,i),tt.swalPromiseReject.set(e,s),t.confirmButton.onclick=()=>{(e=>{const t=he.innerParams.get(e);e.disableButtons(),t.input?Pt(e,"confirm"):Mt(e,!0)})(e)},t.denyButton.onclick=()=>{(e=>{const t=he.innerParams.get(e);e.disableButtons(),t.returnInputValueOnDeny?Pt(e,"deny"):Tt(e,!1)})(e)},t.cancelButton.onclick=()=>{((e,t)=>{e.disableButtons(),t(ze.cancel)})(e,r)},t.closeButton.onclick=()=>{r(ze.close)},((e,t,n)=>{e.toast?un(e,t,n):(mn(t),hn(t),gn(e,t,n))})(n,t,r),((e,t,n)=>{We(e),t.toast||(e.keydownHandler=e=>Ze(t,e,n),e.keydownTarget=t.keydownListenerCapture?window:C(),e.keydownListenerCapture=t.keydownListenerCapture,e.keydownTarget.addEventListener("keydown",e.keydownHandler,{capture:e.keydownListenerCapture}),e.keydownHandlerAdded=!0)})(o,n,r),((e,t)=>{"select"===t.input||"radio"===t.input?kt(e,t):["text","email","number","tel","textarea"].some(e=>e===t.input)&&(g(t.inputValue)||b(t.inputValue))&&(vt(P()),Bt(e,t))})(e,n),jn(n),Yn(o,n,r),Xn(t,n),setTimeout(()=>{t.container.scrollTop=0})}),Wn=(e,t)=>{const n=(e=>{const t="string"==typeof e.template?document.querySelector(e.template):e.template;if(!t)return{};const n=t.content;return On(n),Object.assign(Bn(n),$n(n),Ln(n),Pn(n),xn(n),Tn(n),Sn(n,kn))})(e),o=Object.assign({},zt,t,n,e);return o.showClass=Object.assign({},zt.showClass,o.showClass),o.hideClass=Object.assign({},zt.hideClass,o.hideClass),!1===o.animation&&(o.showClass={backdrop:"swal2-noanimation"},o.hideClass={}),o},Kn=e=>{const t={popup:C(),container:y(),actions:O(),confirmButton:P(),denyButton:T(),cancelButton:x(),loader:S(),closeButton:H(),validationMessage:L(),progressSteps:$()};return he.domCache.set(e,t),t},Yn=(e,t,n)=>{const o=j();Z(o),t.timer&&(e.timeout=new En(()=>{n("timer"),delete e.timeout},t.timer),t.timerProgressBar&&(X(o),_(o,t,"timerProgressBar"),setTimeout(()=>{e.timeout&&e.timeout.running&&oe(t.timer)})))},Xn=(e,t)=>{if(!t.toast)return h(t.allowEnterKey)?void(Zn(e)||Jn(e,t)||Ke(-1,1)):(m("allowEnterKey"),void Gn())},Zn=e=>{const t=Array.from(e.popup.querySelectorAll("[autofocus]"));for(const e of t)if(e instanceof HTMLElement&&ee(e))return e.focus(),!0;return!1},Jn=(e,t)=>t.focusDeny&&ee(e.denyButton)?(e.denyButton.focus(),!0):t.focusCancel&&ee(e.cancelButton)?(e.cancelButton.focus(),!0):!(!t.focusConfirm||!ee(e.confirmButton))&&(e.confirmButton.focus(),!0),Gn=()=>{document.activeElement instanceof HTMLElement&&"function"==typeof document.activeElement.blur&&document.activeElement.blur()};Un.prototype.disableButtons=Nt,Un.prototype.enableButtons=qt,Un.prototype.getInput=It,Un.prototype.disableInput=Ft,Un.prototype.enableInput=_t,Un.prototype.hideLoading=jt,Un.prototype.disableLoading=jt,Un.prototype.showValidationMessage=Rt,Un.prototype.resetValidationMessage=Ut,Un.prototype.close=dt,Un.prototype.closePopup=dt,Un.prototype.closeModal=dt,Un.prototype.closeToast=dt,Un.prototype.rejectPromise=mt,Un.prototype.update=nn,Un.prototype._destroy=sn,Object.assign(Un,An),Object.keys(cn).forEach(e=>{Un[e]=function(...t){return Fn&&Fn[e]?Fn[e](...t):null}}),Un.DismissReason=ze,Un.version="11.26.3";const Qn=Un;return Qn.default=Qn,Qn}),void 0!==this&&this.Sweetalert2&&(this.swal=this.sweetAlert=this.Swal=this.SweetAlert=this.Sweetalert2); \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/templates/detail.html b/fst_data_pipeline/apps/mta_manage_system/templates/detail.html new file mode 100644 index 0000000..ee7b0b5 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/templates/detail.html @@ -0,0 +1,916 @@ + + + + + + 数据包选择页面 + + + + + + +

    数据包选择页面

    + +
    + + +
    + +
    +

    {{ html_name }}

    +
    + + +
    + +
    + + +
    +
    + 选择类型(单选) +
    + + + +
    +
    +
    +
    + + + + + +
    + +
    + +
    + + +
    + + +
    +
    +
    +
    + + + + + + + + +
    + + +
    + +
    + + + + + + +
    + +
    +
    + +
    + +
    + +
    + +
    +

    图片展示

    +
    +
    + 图片预览 +
    +
    + +
    + + + +
    未选中
    + +
    + + + + +
    + central session(单选) +
    +
    + +
    选择的数据包:
    + +
    + query session(可多选) +
    +
    + + + +
    + + +
    +
    + + + + + + \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/templates/list.html b/fst_data_pipeline/apps/mta_manage_system/templates/list.html new file mode 100644 index 0000000..30978d5 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/templates/list.html @@ -0,0 +1,697 @@ + + + + + + SessionRecord 列表 + + + + + + + + + +
    +
    +
    +
    SessionRecord 列表
    + +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    + 是否筛选过数据包 + +
    + + + + + + + + +
    + mta 任务状态 + +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + + +
    + + + + + + + + + + + + + + + + + + + + + +
    IDHTML NameCentral SessionQuery SessionsHtml Folder筛选数据包MTA Task 状态MTA 结果备注创建时间更新时间操作
    +
    + + +
    +
    + +
    +
    +
    +
    + + + + + image + + + + + + + + + + \ No newline at end of file diff --git a/fst_data_pipeline/apps/mta_manage_system/utils/docker_tool.py b/fst_data_pipeline/apps/mta_manage_system/utils/docker_tool.py new file mode 100644 index 0000000..dfaf552 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/utils/docker_tool.py @@ -0,0 +1,445 @@ +import subprocess +import os +import json +import socket +from typing import Dict, List, Optional + + +class DockerTool: + """Docker容器管理工具类""" + + def __init__(self): + self.docker_available = self._check_docker_env() + + def _check_docker_env(self) -> bool: + """检查宿主机Docker环境""" + try: + result = subprocess.run( + ["docker", "info"], capture_output=True, text=True, timeout=5 + ) + return result.returncode == 0 + except Exception as e: + print(f"Docker环境检查失败: {e}") + return False + + def is_docker_available(self) -> bool: + """返回Docker环境是否可用""" + return self.docker_available + + def get_container_detailed_info(self, container_id: str) -> Dict: + """ + 获取容器的详细信息(使用docker inspect命令) + + Args: + container_id: 容器ID(完整ID或短ID) + + Returns: + 包含容器详细信息的字典 + """ + try: + result = subprocess.run( + ["docker", "inspect", container_id], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + try: + inspect_data = json.loads(result.stdout.strip()) + if inspect_data: + return { + "success": True, + "inspect_data": inspect_data[0], + "message": "获取容器详细信息成功", + } + except json.JSONDecodeError as e: + return { + "success": False, + "error": f"解析容器信息失败: {e}", + "message": "解析容器信息失败", + } + else: + return { + "success": False, + "error": result.stderr.strip(), + "message": f"获取容器 '{container_id}' 详细信息失败", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "获取容器详细信息过程中发生异常", + } + + def get_container_status_by_name(self, container_name: str) -> Dict: + """ + 通过容器名称查询容器状态 + + Args: + container_name: 容器名称 + + Returns: + 包含容器状态信息的字典 + """ + try: + result = subprocess.run( + [ + "docker", + "ps", + "-a", + "--filter", + f"name={container_name}", + "--format", + "{{json .}}", + ], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + output = result.stdout.strip() + if not output: + return { + "success": False, + "error": f"未找到名为 '{container_name}' 的容器", + "message": "容器不存在", + } + + # 处理多行输出(可能有多个匹配的容器) + lines = [line for line in output.split("\n") if line.strip()] + containers = [json.loads(line) for line in lines] + + # 精确匹配容器名称(排除名称中包含查询字符串的容器) + exact_match = None + for container in containers: + # Docker返回的名称格式可能是 "/容器名" 或 "容器名" + names = container.get("Names", "") + if container_name in names: + # 检查是否为精确匹配 + name_list = names.split(",") + for name in name_list: + clean_name = name.strip("/") # 去除开头的斜杠 + if clean_name == container_name: + exact_match = container + break + + if exact_match: + break + + if not exact_match and containers: + # 如果没有精确匹配,但有多条结果,返回第一个 + exact_match = containers[0] + + if exact_match: + return { + "success": True, + "container": exact_match, + "status": exact_match.get("Status", ""), + "state": exact_match.get("State", ""), + "message": "获取容器状态成功", + } + else: + return { + "success": False, + "error": f"未找到精确匹配的容器 '{container_name}'", + "message": "容器不存在", + } + else: + return { + "success": False, + "error": result.stderr.strip(), + "message": f"查询容器 '{container_name}' 状态失败", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "查询容器状态过程中发生异常", + } + + def get_container_status_by_id(self, container_id: str) -> Dict: + """ + 通过容器ID查询容器状态 + + Args: + container_id: 容器ID(完整ID或短ID) + + Returns: + 包含容器状态信息的字典 + """ + try: + # 使用docker ps命令查询特定容器的状态 + result = subprocess.run( + [ + "docker", + "ps", + "-a", + "--filter", + f"id={container_id}", + "--format", + "{{json .}}", + ], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + output = result.stdout.strip() + if not output: + return { + "success": False, + "error": f"未找到ID为 '{container_id}' 的容器", + "message": "容器不存在", + } + + # 处理多行输出(理论上应该只有一行,因为ID是唯一的) + lines = [line for line in output.split("\n") if line.strip()] + if not lines: + return { + "success": False, + "error": f"未找到ID为 '{container_id}' 的容器", + "message": "容器不存在", + } + + # 解析容器信息 + container_info = json.loads(lines[0]) + + return { + "success": True, + "container": container_info, + "container_id": container_info.get("ID", ""), + "container_name": container_info.get("Names", ""), + "status": container_info.get("Status", ""), + "state": container_info.get("State", ""), + "image": container_info.get("Image", ""), + "command": container_info.get("Command", ""), + "created": container_info.get("CreatedAt", ""), + "ports": container_info.get("Ports", ""), + "message": "获取容器状态成功", + } + + else: + return { + "success": False, + "error": result.stderr.strip(), + "message": f"查询容器 '{container_id}' 状态失败", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "查询容器状态过程中发生异常", + } + + def stop_container_by_name(self, container_name: str) -> Dict: + """ + 通过容器名称停止容器 + + Args: + container_name: 容器名称 + + Returns: + 包含停止结果的字典 + """ + try: + # 首先通过容器名称查询容器状态 + status_result = self.get_container_status_by_name(container_name) + + if not status_result["success"]: + return { + "success": False, + "error": status_result.get("error", "查询容器状态失败"), + "message": f"无法找到容器 '{container_name}'", + } + + # 获取容器ID(可能是短ID或完整ID) + container_info = status_result["container"] + container_id = container_info.get("ID", "") # Docker ps 返回的可能是短ID + + if not container_id: + return { + "success": False, + "error": "无法获取容器ID", + "message": f"从容器信息中无法提取容器ID", + } + + # 使用现有的stop_container方法停止容器 + stop_result = self.stop_container(container_id) + + if stop_result["success"]: + return { + "success": True, + "container_id": container_id, + "container_name": container_name, + "message": f"容器 '{container_name}' 已成功停止", + } + else: + return { + "success": False, + "error": stop_result.get("error", "停止操作失败"), + "message": f"停止容器 '{container_name}' 失败", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "通过容器名停止容器过程中发生异常", + } + + def create_container( + self, + image_name: str = "ubuntu:latest", + container_name: Optional[str] = None, + command: Optional[str] = None, + volumes: Optional[Dict[str, str]] = None, + environment: Optional[Dict[str, str]] = None, + interactive: bool = False, + ) -> Dict: + """ + 创建新容器 + + Args: + image_name: 镜像名称 + container_name: 容器名称 + command: 启动命令 + volumes: 路径映射字典,格式为 {宿主机路径: 容器内路径} + environment: 环境变量字典,格式为 {变量名: 变量值} + interactive: 是否以交互模式运行 + + Returns: + 包含创建结果的字典 + """ + try: + # 构建Docker命令 + docker_command = ["docker", "run", "-d"] + # 添加交互模式选项 + # if interactive: + # docker_command.extend(["-it"]) + + # 添加容器名称 + if container_name: + docker_command.extend(["--name", container_name]) + + # 添加路径映射 + if volumes: + for host_path, container_path in volumes.items(): + docker_command.extend(["-v", f"{host_path}:{container_path}"]) + + # 添加环境变量 + if environment: + for key, value in environment.items(): + docker_command.extend(["-e", f"{key}={value}"]) + + # 添加镜像名称和命令 + docker_command.append(image_name) + if command: + docker_command.extend(["/bin/bash", "-c", command]) + + # 执行Docker命令 + result = subprocess.run( + docker_command, capture_output=True, text=True, timeout=30 + ) + + if result.returncode == 0: + container_id = result.stdout.strip() + return { + "success": True, + "container_id": container_id, + "message": "容器创建成功", + } + else: + return { + "success": False, + "error": result.stderr.strip(), + "message": "容器创建失败", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "容器创建过程中发生异常", + } + + def get_containers(self) -> List[Dict]: + """获取所有容器列表""" + try: + result = subprocess.run( + ["docker", "ps", "-a", "--format", "{{json .}}"], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + containers = [] + for line in result.stdout.strip().split("\n"): + if line: + containers.append(json.loads(line)) + return containers + return [] + + except Exception as e: + print(f"获取容器列表失败: {e}") + return [] + + def stop_container(self, container_id: str) -> Dict: + """停止指定容器""" + try: + result = subprocess.run( + ["docker", "stop", container_id], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + return {"success": True, "message": f"容器 {container_id} 已停止"} + else: + return { + "success": False, + "error": result.stderr.strip(), + "message": f"停止容器 {container_id} 失败", + } + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "停止容器过程中发生异常", + } + + def remove_container(self, container_id: str) -> Dict: + """删除指定容器""" + try: + result = subprocess.run( + ["docker", "rm", container_id], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + return {"success": True, "message": f"容器 {container_id} 已删除"} + else: + return { + "success": False, + "error": result.stderr.strip(), + "message": f"删除容器 {container_id} 失败", + } + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "删除容器过程中发生异常", + } + + +# 创建全局工具实例 +docker_tool = DockerTool() diff --git a/fst_data_pipeline/apps/mta_manage_system/utils/extract_package_tool.py b/fst_data_pipeline/apps/mta_manage_system/utils/extract_package_tool.py new file mode 100644 index 0000000..d8f9b84 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/utils/extract_package_tool.py @@ -0,0 +1,30 @@ +from typing import List, Set, Dict +import re +import os + + +def extract_package_names_from_html(html_file: str, logger) -> Set[str]: + """ + 从单个HTML文件中提取所有legendgroup的包名 + """ + try: + with open(html_file, "r", encoding="utf-8") as file: + content = file.read() + except UnicodeDecodeError: + try: + with open(html_file, "r", encoding="gbk", errors="ignore") as file: + content = file.read() + except Exception as e: + logger.error(f"读取文件 {html_file} 失败: {e}") + return set() + + # 使用正则表达式匹配所有legendgroup中的包名 + pattern = r'"legendgroup":"([^"]+)"' + package_names = re.findall(pattern, content) + + unique_packages = set(package_names) + logger.info( + f"文件 {os.path.basename(html_file)} 中找到 {len(unique_packages)} 个包名" + ) + + return unique_packages diff --git a/fst_data_pipeline/apps/mta_manage_system/utils/log_tool.py b/fst_data_pipeline/apps/mta_manage_system/utils/log_tool.py new file mode 100644 index 0000000..26a7fa1 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/utils/log_tool.py @@ -0,0 +1,45 @@ +import logging +import sys +from pathlib import Path + + +def setup_logging(log_file="app.log", level=logging.INFO): + """配置日志系统 + + Args: + log_file: 日志文件路径 + level: 日志级别 + """ + # 创建日志目录(如果不存在) + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + # 创建 logger + logger = logging.getLogger(__name__) + logger.setLevel(level) + + # 避免重复配置 + if logger.handlers: + return logger + + # 创建 formatter + formatter = logging.Formatter("[%(levelname)s] %(message)s") + + # 文件处理器(追加模式) + file_handler = logging.FileHandler(filename=log_file, mode="a", encoding="utf-8") + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + + # 控制台处理器 + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + + # 添加处理器 + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + # 防止重复记录 + logger.propagate = False + + return logger diff --git a/fst_data_pipeline/apps/mta_manage_system/utils/path_tool.py b/fst_data_pipeline/apps/mta_manage_system/utils/path_tool.py new file mode 100644 index 0000000..465bb0c --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/utils/path_tool.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +""" +路径拼接工具类 +提供简单易用的路径操作功能 +""" + +import os +from pathlib import Path +from typing import Union, List, Optional + + +class PathTool: + """简单的路径拼接工具类""" + + @staticmethod + def join(*paths: Union[str, Path]) -> str: + """ + 拼接多个路径 + + Args: + *paths: 路径组件,可以是字符串或Path对象 + + Returns: + str: 拼接后的路径字符串 + + Example: + >>> PathTool.join("/home", "user", "documents") + '/home/user/documents' + """ + return os.path.join(*paths) + + @staticmethod + def join_with_base(base_path: str, *sub_paths: str) -> str: + """ + 基于基础路径拼接子路径 + + Args: + base_path: 基础路径 + *sub_paths: 子路径列表 + + Returns: + str: 完整的路径字符串 + + Example: + >>> PathTool.join_with_base("/base", "dir1", "dir2", "file.txt") + '/base/dir1/dir2/file.txt' + """ + return PathTool.join(base_path, *sub_paths) + + @staticmethod + def normalize(path: str) -> str: + """ + 规范化路径(处理 ../, ./ 等相对路径) + + Args: + path: 原始路径 + + Returns: + str: 规范化后的路径 + + Example: + >>> PathTool.normalize("/home/user/../user/./documents") + '/home/user/documents' + """ + return os.path.normpath(path) + + @staticmethod + def absolute(path: str) -> str: + """ + 获取绝对路径 + + Args: + path: 相对路径或绝对路径 + + Returns: + str: 绝对路径 + + Example: + >>> PathTool.absolute("documents/file.txt") + '/current/working/directory/documents/file.txt' + """ + return os.path.abspath(path) + + @staticmethod + def exists(path: str) -> bool: + """ + 检查路径是否存在 + + Args: + path: 要检查的路径 + + Returns: + bool: 路径是否存在 + """ + return os.path.exists(path) + + @staticmethod + def is_file(path: str) -> bool: + """ + 检查是否为文件 + + Args: + path: 要检查的路径 + + Returns: + bool: 是否为文件 + """ + return os.path.isfile(path) + + @staticmethod + def is_dir(path: str) -> bool: + """ + 检查是否为目录 + + Args: + path: 要检查的路径 + + Returns: + bool: 是否为目录 + """ + return os.path.isdir(path) + + @staticmethod + def get_filename(path: str) -> str: + """ + 获取文件名(包含扩展名) + + Args: + path: 文件路径 + + Returns: + str: 文件名 + + Example: + >>> PathTool.get_filename("/home/user/file.txt") + 'file.txt' + """ + return os.path.basename(path) + + @staticmethod + def get_parent(path: str) -> str: + """ + 获取父目录路径 + + Args: + path: 文件或目录路径 + + Returns: + str: 父目录路径 + + Example: + >>> PathTool.get_parent("/home/user/file.txt") + '/home/user' + """ + return os.path.dirname(path) + + @staticmethod + def get_extension(path: str) -> str: + """ + 获取文件扩展名 + + Args: + path: 文件路径 + + Returns: + str: 文件扩展名(包含点号) + + Example: + >>> PathTool.get_extension("/home/user/file.txt") + '.txt' + """ + return os.path.splitext(path)[1] + + @staticmethod + def change_extension(path: str, new_extension: str) -> str: + """ + 更改文件扩展名 + + Args: + path: 原文件路径 + new_extension: 新扩展名(包含点号) + + Returns: + str: 更改扩展名后的路径 + + Example: + >>> PathTool.change_extension("/home/user/file.txt", ".log") + '/home/user/file.log' + """ + name_without_ext = os.path.splitext(path)[0] + return name_without_ext + new_extension + + @staticmethod + def create_dirs(path: str) -> bool: + """ + 创建目录(包括父目录) + + Args: + path: 要创建的目录路径 + + Returns: + bool: 是否创建成功 + """ + try: + os.makedirs(path, exist_ok=True) + return True + except Exception: + return False + + +# 创建全局工具实例 +path_tool = PathTool() + +# 使用示例和测试 +if __name__ == "__main__": + # 测试路径拼接 + print("=== 路径拼接测试 ===") + result1 = PathTool.join("/home", "user/", "documents", "file.txt") + print(f"拼接路径: {result1}") + + result2 = PathTool.join_with_base("/base", "dir1", "dir2", "file.txt") + print(f"基础路径拼接: {result2}") + + # 测试路径规范化 + print("\n=== 路径规范化测试 ===") + normalized = PathTool.normalize("/home/user/../user/./documents") + print(f"规范化路径: {normalized}") + + # 测试文件信息获取 + print("\n=== 文件信息测试 ===") + test_path = "/home/user/document.txt" + print(f"文件名: {PathTool.get_filename(test_path)}") + print(f"父目录: {PathTool.get_parent(test_path)}") + print(f"扩展名: {PathTool.get_extension(test_path)}") + + # 测试扩展名更改 + new_path = PathTool.change_extension(test_path, ".log") + print(f"更改扩展名: {new_path}") + + # 测试路径检查 + print("\n=== 路径检查测试 ===") + print(f"路径是否存在: {PathTool.exists('/tmp')}") + print(f"是否为目录: {PathTool.is_dir('/tmp')}") + print(f"是否为文件: {PathTool.is_file('/tmp')}") + + # 使用全局实例 + print("\n=== 全局实例测试 ===") + result = path_tool.join("dir1", "dir2", "file.txt") + print(f"使用全局实例: {result}") diff --git a/fst_data_pipeline/apps/mta_manage_system/utils/task_queue_tool.py b/fst_data_pipeline/apps/mta_manage_system/utils/task_queue_tool.py new file mode 100644 index 0000000..0caeda2 --- /dev/null +++ b/fst_data_pipeline/apps/mta_manage_system/utils/task_queue_tool.py @@ -0,0 +1,398 @@ +import threading +import time +import uuid +import queue +from datetime import datetime +from threading import Thread +from typing import Dict, List, Optional, Callable, Any +from enum import Enum +import logging + + +class TaskStatus(Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class SimpleTask: + """简单的任务类""" + + def __init__(self, BATH_ROOT: str, task_func: Callable, *args, **kwargs): + self.BATH_ROOT = BATH_ROOT + self.task_func = task_func + self.args = args + self.kwargs = kwargs + self.status = TaskStatus.PENDING + self.progress = 0 + self.message = "" + self.result = None + self.error = None + self.created_at = datetime.now() + self.started_at = None + self.completed_at = None + self._cancelled = False + self._thread = None + self.completion_callback = kwargs.pop("completion_callback", None) # 新增回调 + + def execute(self): + """执行任务""" + try: + self.status = TaskStatus.RUNNING + self.started_at = datetime.now() + + # 在新线程中执行 + self._thread = threading.Thread(target=self._run_wrapper, daemon=True) + self._thread.start() + + except Exception as e: + self.status = TaskStatus.FAILED + self.error = str(e) + self.completed_at = datetime.now() + + def _run_wrapper(self): + """任务执行包装器""" + try: + # 执行实际的任务函数 + self.result = self.task_func(self, *self.args, **self.kwargs) + + if self._cancelled: + self.status = TaskStatus.CANCELLED + else: + self.status = TaskStatus.COMPLETED + self.progress = 100 + + except Exception as e: + self.status = TaskStatus.FAILED + self.error = str(e) + # 确保result包含错误信息,这样回调函数能获取到错误 + self.result = {"status": "failed", "error": str(e)} + finally: + self.completed_at = datetime.now() + + # 无论成功还是失败都执行回调 + if self.completion_callback: + try: + # 传递任务结果给回调函数 + callback_result = ( + self.result if self.result else {"status": self.status.value} + ) + self.completion_callback(callback_result) + except Exception as callback_e: + print(f"回调函数执行失败: {callback_e}") + # 确保logger存在 + if hasattr(self, "logger"): + self.logger.error(f"回调函数执行失败: {callback_e}") + else: + print(f"回调函数执行失败: {callback_e}") + + def update_progress(self, progress: int, message: str = ""): + """更新任务进度""" + if not self._cancelled: + self.progress = max(0, min(100, progress)) + self.message = message + + def cancel(self): + """取消任务""" + self._cancelled = True + self.status = TaskStatus.CANCELLED + self.message = "任务已被取消" + + def is_running(self) -> bool: + """检查任务是否正在运行""" + return ( + self.status == TaskStatus.RUNNING + and self._thread + and self._thread.is_alive() + ) + + def to_dict(self) -> Dict[str, Any]: + """转换为字典格式""" + return { + "BATH_ROOT": self.BATH_ROOT, + "status": self.status.value, + "progress": self.progress, + "message": self.message, + "result": self.result, + "error": self.error, + "created_at": self.created_at.isoformat(), + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() + if self.completed_at + else None, + "is_running": self.is_running(), + } + + +class SimpleTaskQueue: + """简单的任务队列管理器""" + + def __init__(self, max_concurrent_tasks: int = 3): + """ + 初始化任务队列 + + Args: + max_concurrent_tasks: 最大并发任务数 + """ + self.max_concurrent_tasks = max_concurrent_tasks + self.pending_queue = queue.Queue() # 等待队列 + self.running_tasks: Dict[str, SimpleTask] = {} # 运行中的任务 + self.completed_tasks: Dict[str, SimpleTask] = {} # 已完成的任务 + self.cancelled_tasks: Dict[str, SimpleTask] = {} # 已取消的任务 + self.lock = threading.RLock() + self.is_running = True + + # 启动调度器线程 + self.scheduler_thread = threading.Thread( + target=self._scheduler_loop, daemon=True + ) + self.scheduler_thread.start() + + # 配置日志 + self.logger = self._setup_logger() + self.logger.info(f"简单任务队列已启动,最大并发数: {max_concurrent_tasks}") + + def _setup_logger(self) -> logging.Logger: + """设置日志""" + logger = logging.getLogger("SimpleTaskQueue") + logger.setLevel(logging.INFO) + if not logger.handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + return logger + + def _scheduler_loop(self): + """调度器循环 - 自动管理任务执行""" + while self.is_running: + try: + with self.lock: + current_running = len(self.running_tasks) + + # 检查是否可以启动新任务 + if ( + current_running < self.max_concurrent_tasks + and not self.pending_queue.empty() + ): + available_slots = self.max_concurrent_tasks - current_running + + for _ in range( + min(available_slots, self.pending_queue.qsize()) + ): + if not self.pending_queue.empty(): + task = self.pending_queue.get_nowait() + self._start_task(task) + self.logger.info( + f"从等待队列启动任务: {task.BATH_ROOT}" + ) + + # 清理已完成的任务 + self._cleanup_completed_tasks() + + # 短暂休眠 + time.sleep(1) + + except Exception as e: + self.logger.error(f"调度器错误: {e}") + time.sleep(5) + + def _cleanup_completed_tasks(self): + """清理已完成的任务""" + completed_ids = [] + with self.lock: + for BATH_ROOT, task in list(self.running_tasks.items()): + if not task.is_running() and task.status in [ + TaskStatus.COMPLETED, + TaskStatus.FAILED, + TaskStatus.CANCELLED, + ]: + completed_ids.append(BATH_ROOT) + if ( + task.status == TaskStatus.COMPLETED + or task.status == TaskStatus.FAILED + ): + self.completed_tasks[BATH_ROOT] = task + elif task.status == TaskStatus.CANCELLED: + self.cancelled_tasks[BATH_ROOT] = task + self.logger.info( + f"任务完成: {BATH_ROOT}, 状态: {task.status.value}" + ) + + # 从运行列表中移除已完成的任务 + for BATH_ROOT in completed_ids: + if BATH_ROOT in self.running_tasks: + self.running_tasks.pop(BATH_ROOT) + + def _start_task(self, task: SimpleTask): + """启动任务""" + with self.lock: + self.running_tasks[task.BATH_ROOT] = task + task.execute() + self.logger.info(f"任务开始执行: {task.BATH_ROOT}") + + def submit_task(self, task_func: Callable, *args, **kwargs) -> Dict[str, Any]: + """ + 提交新任务到队列 + + Args: + task_func: 要执行的任务函数 + *args: 任务函数参数 + **kwargs: 任务函数关键字参数 + + Returns: + 任务提交结果 + """ + BATH_ROOT = str(uuid.uuid4())[:8] + + # 创建任务对象 + task = SimpleTask(BATH_ROOT, task_func, *args, **kwargs) + + with self.lock: + current_running = len(self.running_tasks) + + if current_running < self.max_concurrent_tasks: + # 直接启动任务 + self._start_task(task) + status = "RUNNING" + message = "任务已立即启动" + immediate_start = True + else: + # 添加到等待队列 + self.pending_queue.put(task) + status = "PENDING" + message = f"任务已加入等待队列,当前位置: {self.pending_queue.qsize()}" + immediate_start = False + + self.logger.info(f"提交任务: {BATH_ROOT}, 立即执行: {immediate_start}") + + return { + "BATH_ROOT": BATH_ROOT, + "status": status, + "immediate_start": immediate_start, + "message": message, + "running_count": current_running, + "pending_count": self.pending_queue.qsize(), + "max_concurrent": self.max_concurrent_tasks, + "submitted_at": datetime.now().isoformat(), + } + + def get_task_status(self, BATH_ROOT: str) -> Optional[Dict[str, Any]]: + """获取任务状态""" + with self.lock: + # 检查运行中的任务 + if BATH_ROOT in self.running_tasks: + return self.running_tasks[BATH_ROOT].to_dict() + + # 检查已完成的任务 + if BATH_ROOT in self.completed_tasks: + return self.completed_tasks[BATH_ROOT].to_dict() + + # 检查等待队列中的任务 + for task in list(self.pending_queue.queue): + if task.BATH_ROOT == BATH_ROOT: + return task.to_dict() + + return None + + def cancel_task(self, BATH_ROOT: str) -> Dict[str, Any]: + """取消任务""" + with self.lock: + # 检查运行中的任务 + if BATH_ROOT in self.running_tasks: + task = self.running_tasks[BATH_ROOT] + task.cancel() + self.cancelled_tasks[BATH_ROOT] = task + if BATH_ROOT in self.running_tasks: + self.running_tasks.pop(BATH_ROOT) + self.logger.info(f"取消运行中的任务: {BATH_ROOT}") + return {"success": True, "message": "任务取消请求已发送"} + + # 检查等待队列中的任务 + temp_queue = queue.Queue() + found = False + while not self.pending_queue.empty(): + task = self.pending_queue.get() + if task.task_id == BATH_ROOT: + found = True + task.cancel() + self.cancelled_tasks[BATH_ROOT] = task + self.logger.info(f"取消等待队列中的任务: {BATH_ROOT}") + else: + temp_queue.put(task) + + # 将剩余任务放回原队列 + while not temp_queue.empty(): + self.pending_queue.put(temp_queue.get()) + + if found: + return {"success": True, "message": "任务已从等待队列中取消"} + if BATH_ROOT in self.cancelled_tasks: + return {"success": True, "message": "任务已被取消"} + return {"success": False, "error": "任务不存在"} + + def get_queue_status(self) -> Dict[str, Any]: + """获取队列状态""" + with self.lock: + # 获取运行中任务信息 + running_tasks = [] + for task in self.running_tasks.values(): + task_info = task.to_dict() + running_tasks.append(task_info) + + # 获取等待任务信息 + pending_tasks = [] + temp_queue = queue.Queue() + while not self.pending_queue.empty(): + task = self.pending_queue.get() + pending_tasks.append(task.to_dict()) + temp_queue.put(task) + + # 恢复队列 + while not temp_queue.empty(): + self.pending_queue.put(temp_queue.get()) + + # 分别统计不同状态的任务数量 + successful_count = len([ + t + for t in self.completed_tasks.values() + if t.status == TaskStatus.COMPLETED + ]) + failed_count = len([ + t + for t in self.completed_tasks.values() + if t.status == TaskStatus.FAILED + ]) + cancelled_count = len(self.cancelled_tasks) + + # 获取已取消任务信息 + cancelled_tasks = [] + for task in self.cancelled_tasks.values(): + task_info = task.to_dict() + cancelled_tasks.append(task_info) + + return { + "max_concurrent_tasks": self.max_concurrent_tasks, + "pending_count": self.pending_queue.qsize(), + "running_count": len(self.running_tasks), + "successful_count": successful_count, + "failed_count": failed_count, + "cancelled_count": cancelled_count, # 更新统计 + "cancelled_tasks": cancelled_tasks, # 添加已取消任务列表 + "running_tasks": running_tasks, + "pending_tasks": pending_tasks, + "timestamp": datetime.now().isoformat(), + } + + def stop(self): + """停止任务队列""" + self.is_running = False + self.logger.info("任务队列已停止") + + +# 全局任务队列实例 +task_queue = SimpleTaskQueue(max_concurrent_tasks=3) diff --git a/fst_data_pipeline/apps/root_db_api/.gitignore b/fst_data_pipeline/apps/root_db_api/.gitignore new file mode 100644 index 0000000..57f1cb2 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/.gitignore @@ -0,0 +1 @@ +/.idea/ \ No newline at end of file diff --git a/fst_data_pipeline/apps/root_db_api/DATABASE_FIELDS.md b/fst_data_pipeline/apps/root_db_api/DATABASE_FIELDS.md new file mode 100644 index 0000000..5455844 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/DATABASE_FIELDS.md @@ -0,0 +1,446 @@ +# ROOT DB API - 数据库字段文档 + +本文档详细说明了 ROOT DB API 数据库中所有可用的数据字段,按功能模块分类。 + +## 🗂️ 数据库架构概览 + +数据库包含 **18 个核心业务表** + **1 个审计日志表**,支持: +- 🚗 **自动驾驶数据管理** (Pangu/Minerva) +- 📦 **ROS Bag 文件管理** +- 🌳 **FST (File System Tree) 分类** +- 🏷️ **标签和主题管理** +- 📍 **地理空间数据支持** +- 🎯 **真值数据管理** + +--- + +## 📊 1. 基础配置模块 + +### 1.1 项目表 (`project`) +管理数据来源项目信息 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 项目自增主键 | +| `name` | VARCHAR(255) | NOT NULL UNIQUE | 项目名称,全局唯一 | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 最近一次更新时间 | + +**说明**: 数据来源标识,1:DFDI,2:CFDI + +--- + +## 📚 2. 字典数据模块 + +### 2.1 驾驶模式字典 (`drive_mode`) +自动驾驶模式分类字典 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `type` | VARCHAR(100) | NOT NULL | 驾驶模式大类 | +| `sub_type` | VARCHAR(100) | | 驾驶模式子类 | +| `reserved_json` | JSONB | | 预留扩展 JSON | +| `comment` | TEXT | | 备注 | + +**驾驶模式类型**: +- **AD_DRIVE_OFF**: OFF, PASSIVE, STANDBY +- **AD_DRIVE_ACC**: ACC +- **AD_DRIVE_CP**: CP +- **AD_DRIVE_NP**: HDMAP_HNP, DDLD_HNP, HDMAP_HNP_PLUS, DDLD_HNP_PLUS, HDMAP_UNP, DDLD_UNP +- **AD_PARK_APA**: 各种 APA 停车模式 (20+ 种) +- **AD_PARK_MPA**: 停车场地图相关模式 + +### 2.2 Topic 字典 (`topic_list`) +ROS Topic 信息管理 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `name` | VARCHAR(255) | NOT NULL UNIQUE | Topic 名称 (如 /lidar/points) | +| `type` | VARCHAR(255) | | Topic 类型 (如 sensor_msgs/PointCloud2) | +| `key_data` | TEXT | | 关键数据摘要 | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +### 2.3 标签字典 (`reserved_tag_list`) +数据标签管理 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `name` | VARCHAR(255) | NOT NULL | 标签名称 | +| `type` | VARCHAR(50) | | 标签分类 | +| `creator` | VARCHAR(255) | | 创建人 | +| `comments` | TEXT | | 备注 | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 | +| `is_deleted` | BOOLEAN | DEFAULT FALSE | 逻辑删除标记 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +### 2.4 真值元数据 (`gt_meta`) +Ground Truth 数据元信息 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `name` | VARCHAR(255) | NOT NULL | 真值名称 | +| `type` | VARCHAR(100) | NOT NULL | 真值类型 (如 3DBox/Lane) | +| `path` | TEXT | NOT NULL | 真值文件存储路径 | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 | +| `reserved_json` | JSONB | | 预留扩展 JSON | +| `comment` | TEXT | | 备注 | + +--- + +## 📦 3. Bag 文件管理模块 + +### 3.1 Bag 主表 (`bag_list`) +ROS Bag 文件核心信息 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `name` | VARCHAR(255) | NOT NULL UNIQUE | Bag 文件名称,全局唯一 | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 | +| `project_id` | INT | FK → project(id) | 数据来源项目 | +| `tile_id` | VARCHAR(255) | | 瓦片 ID | +| `is_decoded` | BOOLEAN | DEFAULT FALSE | 是否已解码 | +| `is_deleted` | BOOLEAN | DEFAULT FALSE | 逻辑删除 | +| `od_annotated` | INT | | 目标检测标注状态 | +| `ld_annotated` | INT | | 车道线标注状态 | +| `fst_indexed` | BOOLEAN | DEFAULT FALSE | 是否已建立 FST 索引 | +| `is_active_data` | BOOLEAN | DEFAULT TRUE | 是否为活跃数据 | +| `reserved_str` | VARCHAR(255) | | 预留字符串 | +| `reserved_json` | JSONB | | 预留扩展 JSON | +| `drive_mode` | SMALLINT | FK → drive_mode(id) | 驾驶模式字典 ID | +| `sw_version` | VARCHAR(100) | | 软件版本号 | +| `hw_version` | VARCHAR(100) | | 硬件版本号 | + +### 3.2 Bag 生命周期 (`bag_lifecycle`) +Bag 文件处理流程时间戳 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `bag_name` | VARCHAR(255) | NOT NULL UNIQUE, FK → bag_list(name) | Bag 名称 | +| `collect_time` | TIMESTAMPTZ | | 采集完成时间 | +| `clone2dev_time` | TIMESTAMPTZ | | 拷贝到bucket时间 | +| `decode_time` | TIMESTAMPTZ | | 解码完成时间 | +| `mining_time` | TIMESTAMPTZ | | 数据挖掘完成时间 | +| `auto_annotate_time` | TIMESTAMPTZ | | 自动标注完成时间 | +| `manual_annotate_time` | TIMESTAMPTZ | | 人工标注完成时间 | +| `fst_index_time` | TIMESTAMPTZ | | FST 索引完成时间 | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录更新时间 | + +--- + +## 🚗 4. Pangu 数据管理模块 + +### 4.1 Pangu 主表 (`main_pangu`) +Pangu 原始数据信息 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `name` | VARCHAR(255) | NOT NULL UNIQUE | 名称,全局唯一 | +| `vehicle` | VARCHAR(255) | | 车辆编号 | +| `datetime` | TIMESTAMPTZ | | rosbag的生成时间 | +| `bag_path` | TEXT | | 原始 Bag 路径 | +| `data_path` | TEXT | | 解析后数据路径 | +| `reserved_str` | VARCHAR(255) | | 预留字符串 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +### 4.2 Pangu 融合表 (`joined_pangu`) +融合后的 Pangu 数据 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `name` | VARCHAR(255) | NOT NULL UNIQUE | 融合后名称 | +| `data_path` | TEXT | | 融合后数据路径 | +| `reserved_str` | VARCHAR(255) | | 预留字符串 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +### 4.3 Bag 融合关系 (`joined_bags`) +记录 Bag 文件融合关系 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `parent_id` | INT | NOT NULL | 父 Bag ID | +| `child_id` | INT | NOT NULL UNIQUE | 子 Bag ID,唯一 | +| `reserved_str` | VARCHAR(255) | | 预留字符串 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +### 4.4 Pangu 详细路径 (`secondary_pangu`) +Pangu 数据文件路径详情 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `bag_id` | INT | NOT NULL UNIQUE, FK → bag_list(id) | 关联 bag_list.id | +| `lidar_gt_pandar128` | TEXT | | 激光真值路径 | +| `object_lidar_gt_pandar128_manual` | TEXT | | 人工真值路径 | +| `lidar_fd_multi_scan_raw` | TEXT | | 原始雷达路径 | +| `camera_fisheye_left` | TEXT | | 左鱼眼相机路径 | +| `camera_fisheye_right` | TEXT | | 右鱼眼相机路径 | +| `camera_front_wide` | TEXT | | 前广角相机路径 | +| `raw_gps` | TEXT | | 原始 GPS 路径 | +| `raw_imu` | TEXT | | 原始 IMU 路径 | +| `ego_motion` | TEXT | | 自车运动路径 | +| `vehicle_wheel` | TEXT | | 轮速路径 | +| `calibration` | TEXT | | 标定文件路径 | +| `sdmap` | TEXT | | SD 地图路径或地图版本标识 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +--- + +## 🅰️ 5. Minerva 数据管理模块 + +### 5.1 Minerva 主表 (`main_minerva`) +Minerva 数据会话信息 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `session_id` | VARCHAR(255) | UNIQUE | 会话 ID,唯一 | +| `start_ts` | TIMESTAMPTZ | | 开始时间戳 | +| `end_ts` | TIMESTAMPTZ | | 结束时间戳 | +| `length` | INT | | 时长(秒) | +| `datetime` | TIMESTAMPTZ | | 数据日期 | +| `vin` | VARCHAR(255) | | 车辆识别码 | +| `platform` | VARCHAR(255) | | 平台名称 | +| `mapped` | BOOLEAN | DEFAULT FALSE | 是否已映射 | +| `path` | TEXT | | 原始数据路径 | +| `converted_path` | TEXT | | 转换后路径 | +| `gt_path` | TEXT | | 真值路径 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +### 5.2 Minerva 详细路径 (`secondary_minerva`) +Minerva 数据文件路径详情 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `bag_id` | INT | NOT NULL UNIQUE, FK → bag_list(id) | 关联 bag_list.id | +| `lidar_gt_top_p128` | TEXT | | 顶部Lidar真值路径 | +| `lidar_parking_gt_front_p128` | TEXT | | 前停车Lidar真值路径 | +| `lidar_parking_gt_left_p128` | TEXT | | 左停车Lidar真值路径 | +| `lidar_parking_gt_right_p128` | TEXT | | 右停车Lidar真值路径 | +| `lidar_parking_gt_rear_p128` | TEXT | | 后停车Lidar真值路径 | +| `camera_fisheye_left_200fov` | TEXT | | 左 200° 鱼眼路径 | +| `camera_fisheye_right_200fov` | TEXT | | 右 200° 鱼眼路径 | +| `camera_fisheye_rear_200fov` | TEXT | | 后 200° 鱼眼路径 | +| `camera_fisheye_front_200fov` | TEXT | | 前 200° 鱼眼路径 | +| `camera_front_wide_120fov` | TEXT | | 前 120° 广角路径 | +| `camera_front_tele_30fov` | TEXT | | 前 30° 长焦路径 | +| `camera_rear_right_70fov` | TEXT | | 右后 70° 相机路径 | +| `camera_rear_left_70fov` | TEXT | | 左后 70° 相机路径 | +| `raw_gps` | TEXT | | 原始 GPS 路径 | +| `raw_imu` | TEXT | | 原始 IMU 路径 | +| `ego_motion` | TEXT | | 自车运动路径 | +| `calibration` | TEXT | | 标定文件路径 | +| `rig` | TEXT | | rig 文件路径 | +| `fst_new` | TEXT | | 新 FST 路径 | +| `fst_old` | TEXT | | 旧 FST 路径 | +| `comments` | TEXT | | 备注 | +| `reserved_json` | JSONB | | 预留扩展 JSON | + +--- + +## 🌳 6. FST (File System Tree) 模块 + +### 6.1 FST 节点表 (`fst`) +文件系统树分类结构 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `name` | VARCHAR(255) | NOT NULL | 节点名称 | +| `parent_id` | INT | FK → fst(id) | 父节点 ID | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 | +| `reserved_json` | JSONB | | 预留扩展 JSON | +| `bag_sum` | INT | DEFAULT 0 | 子树 Bag 数量 | + +### 6.2 FST-Bag 关联 (`fst_bag`) +FST 节点与 Bag 文件的关联关系 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `bag_id` | INT | NOT NULL, FK → bag_list(id) | Bag ID | +| `fst_node_id` | INT | NOT NULL, FK → fst(id) | FST 节点 ID | +| `fst_node_level` | INT | NOT NULL | 节点层级 | +| `event_start_time` | INT | | 事件开始时间(ms) | +| `event_end_time` | INT | | 事件结束时间(ms) | +| `img_url` | VARCHAR(255) | | 事件截图 URL | +| `video_url` | VARCHAR(255) | | 事件视频 URL | +| `comments` | VARCHAR(255) | | 备注 | + +--- + +## 🗺️ 7. 地理空间数据模块 + +### 7.1 几何信息表 (`geometry_info`) +Bag 文件的地理空间轨迹数据 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `rosbag_name` | VARCHAR(255) | NOT NULL | Rosbag 名称 | +| `gnss_downsampled_points` | geometry[] | | 降采样 GNSS 轨迹点 | +| `update_time` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 | +| `is_overlapped` | BOOLEAN | DEFAULT FALSE | 是否与其他 Bag 重叠 | + +--- + +## 🔗 8. 关联关系模块 + +### 8.1 Bag-Topic 关联 (`bag_topic`) +Bag 文件与 ROS Topic 的多对多关系 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `bag_id` | INT | NOT NULL, FK → bag_list(id) | Bag ID | +| `topic_id` | INT | NOT NULL, FK → topic_list(id) | Topic ID | + +### 8.2 Bag-标签 关联 (`bag_reserved_tag`) +Bag 文件与标签的多对多关系 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `bag_id` | INT | NOT NULL, FK → bag_list(id) | Bag ID | +| `tag_id` | INT | NOT NULL, FK → reserved_tag_list(id) | 标签 ID | + +### 8.3 Bag-真值 关联 (`bag_gt`) +Bag 文件与真值数据的多对多关系 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | SERIAL | PRIMARY KEY | 主键 | +| `gt_id` | INT | NOT NULL, FK → gt_meta(id) | 真值元数据 ID | +| `bag_id` | INT | NOT NULL, FK → bag_list(id) | Bag ID | +| `comment` | TEXT | | 备注 | + +--- + +## 📝 9. 审计日志模块 + +### 9.1 操作历史 (`ops_history`) +通用审计日志,记录所有数据变更 + +| 字段名 | 类型 | 约束 | 说明 | +|--------|------|------|------| +| `id` | BIGSERIAL | PRIMARY KEY | 审计日志自增主键 | +| `table_name` | REGCLASS | NOT NULL | 被审计表 | +| `row_pk` | TEXT | NOT NULL | 行主键值 | +| `column_name` | TEXT | NOT NULL | 被修改列名 | +| `old_value` | TEXT | | 旧值 | +| `new_value` | TEXT | | 新值 | +| `changed_by` | NAME | NOT NULL DEFAULT CURRENT_USER | 数据库用户 | +| `changed_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 变更时间 | +| `comment` | TEXT | | 可读描述 | +| `app_module` | TEXT | | 应用模块 | +| `trace_id` | TEXT | | 链路追踪 ID | + +--- + +## 📊 10. 数据统计信息 + +### 📈 表数据规模 +根据字段设计和索引配置,预估的数据规模: + +| 表名 | 预估记录数 | 主要用途 | 关键字段 | +|------|------------|----------|----------| +| `bag_list` | 10K+ | Bag文件管理 | name, project_id, is_decoded | +| `main_pangu` | 10K+ | Pangu数据 | name, vehicle, datetime | +| `main_minerva` | 5K+ | Minerva会话 | session_id, vin, platform | +| `fst` | 1K+ | FST分类树 | name, parent_id, bag_sum | +| `topic_list` | 500+ | ROS Topic字典 | name, type | +| `reserved_tag_list` | 1K+ | 标签字典 | name, type, creator | +| `bag_topic` | 100K+ | Bag-Topic关联 | bag_id, topic_id | +| `fst_bag` | 50K+ | FST-Bag关联 | bag_id, fst_node_id | +| `geometry_info` | 10K+ | 地理轨迹 | rosbag_name, gnss_points | + +### 🔍 关键索引 + +**性能优化索引**: +- `idx_bag_list_project_id` - 按项目查询 +- `idx_main_pangu_datetime` - 按时间范围查询 +- `idx_main_minerva_session_id` - 会话ID查询 +- `idx_fst_name` - FST节点名称查询 +- `idx_bag_list_json_gin` - JSON字段GIN索引 + +**复合索引**: +- `idx_bag_list_project_active` - 活跃项目数据 +- `uq_taglist_name_not_deleted` - 未删除标签唯一性 +- `idx_lifecycle_*_time` - 生命周期时间BRIN索引 + +--- + +## 🚀 API 数据访问 + +### 常用查询模式 + +#### 1. 获取项目下所有Bag +```sql +SELECT bl.*, p.name as project_name +FROM bag_list bl +JOIN project p ON bl.project_id = p.id +WHERE p.name = 'project_name' AND bl.is_deleted = FALSE; +``` + +#### 2. 获取Bag的完整信息 +```sql +SELECT + bl.*, + sp.camera_front_wide, + sp.lidar_gt_pandar128, + mp.vehicle, + mp.datetime +FROM bag_list bl +LEFT JOIN secondary_pangu sp ON bl.id = sp.bag_id +LEFT JOIN main_pangu mp ON bl.name = mp.name +WHERE bl.name = 'bag_name'; +``` + +#### 3. 获取FST树结构 +```sql +WITH RECURSIVE fst_tree AS ( + SELECT id, name, parent_id, 0 as level + FROM fst WHERE parent_id IS NULL + UNION ALL + SELECT f.id, f.name, f.parent_id, ft.level + 1 + FROM fst f + JOIN fst_tree ft ON f.parent_id = ft.id +) +SELECT * FROM fst_tree ORDER BY level, name; +``` + +--- + +## 📚 扩展信息 + +### JSON 扩展字段 +多个表包含 `reserved_json` 字段,支持: +- **动态扩展**: 无需修改表结构添加新字段 +- **复杂数据**: 存储嵌套或数组类型数据 +- **高效查询**: 支持 GIN 索引的JSON查询 +- **版本兼容**: 向后兼容的数据扩展 + +### PostGIS 地理支持 +- **空间扩展**: PostGIS + UUID-OSSP 扩展 +- **几何类型**: 支持 geometry[] 数组类型 +- **轨迹数据**: GNSS 降采样轨迹点存储 +- **空间查询**: 支持地理范围和重叠检测 + +### 数据一致性 +- **外键约束**: 保证数据完整性 +- **唯一约束**: 防止重复数据 +- **级联删除**: 自动清理关联数据 +- **软删除**: 逻辑删除保留数据历史 + +--- + +**文档版本**: v1.0 | **更新时间**: 2024-01-14 | **数据库版本**: PostgreSQL 13+ with PostGIS \ No newline at end of file diff --git a/fst_data_pipeline/apps/root_db_api/FST_fst_stash_changes.md b/fst_data_pipeline/apps/root_db_api/FST_fst_stash_changes.md new file mode 100644 index 0000000..49423a3 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/FST_fst_stash_changes.md @@ -0,0 +1,393 @@ +# fst_stash 设计与接口说明 + +本文档记录与 `fst_stash` 相关的表结构、核心服务函数以及 API 接口,方便前后端统一理解“导入/草稿版 FST 树”的读写方式。 + +--- + +## 一、表设计:fst_stash + +**表名**:`fst_stash` + +- `id`:`bigint`,主键,自增 +- `fst_versions`:`varchar(255)`,非空 + - 外键,关联 `version_list.version` + - 表示该条 stash 对应的版本号(例如 `MB Driving FST V1`) +- `content`:`jsonb`,可空 + - 存放完整的 FST JSON 内容(通常包含 `meta` 和 `nodes`) +- `created_time`:`timestamptz`,非空,默认当前时间 +- `updated_time`:`timestamptz`,非空,默认当前时间,更新时自动刷新 + +约束与关系: + +- `fst_versions` 外键指向 `version_list.version`,`ON DELETE CASCADE` + 删除某个版本时,对应的 stash 会自动被删除。 + +内容约定(非数据库约束): + +- 推荐结构: + + ```json + { + "meta": { + "desc": "MB Driving FST V1", + "creator": "xxx", + "created_at": "2026-02-25T10:00:00Z" + }, + "nodes": [ + { + "id": 1, + "name": "ROOT", + "parentId": null, + "treeLevel": 1, + "node_version": 1 + }, + { + "id": 2, + "name": "CHILD", + "parentId": 1, + "treeLevel": 2, + "node_version": 1 + } + ] + } + ``` + +- 接口侧只要求 `nodes` 为数组,并且每个节点至少包含: + - `id`:用于 parentId 关联 + - `name`:节点名称 + - `parentId`:父节点 id(根节点为 `null`) +- 节点级乐观锁字段: + - `node_version`:节点版本号,默认值 `1` + - 写入时若缺失会自动补齐为 `1` + - `/fst/stash/update` 默认开启乐观锁校验,不一致返回 `409` + +--- + +## 二、核心服务函数(service.py) + +文件位置:`src/core/service.py` + +### 1. `upsert_fst_stash` + +```python +def upsert_fst_stash( + db: Session, + *, + version: str, + content: Optional[Dict[str, Any]], + use_node_optimistic_lock: bool = False, +) -> "FstStash": + """ + Insert or update FST stash record for a version. + If a record exists for the given version, update its content. + If not, create a new record. + """ +``` + +行为说明: + +- 先检查 `version_list` 中是否存在 `version`: + - 若不存在,抛出 `ValueError("Version '' not found in version_list")` +- 再按 `fst_versions = version` 查询 `fst_stash`: + - 若已存在:更新其 `content`(仅当传入 `content` 非 `None` 时) + - 若不存在:插入新行,写入 `fst_versions` 和 `content` +- 节点版本规则: + - 遍历 `nodes` 及其 `children`,自动补齐 `node_version`(默认 `1`) + - 更新时若节点内容变更,该节点 `node_version` 自动 `+1` + - 当 `use_node_optimistic_lock=True` 时,校验传入 `node_version` 与库内一致,否则抛出冲突异常 +- `db.flush()` 后返回对应的 `FstStash` 对象 + +### 2. `get_fst_stash` + +```python +def get_fst_stash(db: Session, version: str) -> Optional["FstStash"]: + """Get FST stash record by version.""" +``` + +行为说明: + +- 直接根据 `fst_versions = version` 查询并返回 `FstStash` 实体 +- 找不到则返回 `None` + +--- + +## 三、API 接口列表(fst.py) + +文件位置:`src/api/fst.py` + +### 1. POST `/fst/stash` —— 保存 stash 内容 + +对应函数:`save_fst_stash` + +用途: + +- 通用的保存接口,根据 `version` 写入 `fst_stash.content`。 +- 适合“导入 JSON 后一次性保存”的场景。 + +请求: + +```json +POST /fst/stash +Content-Type: application/json + +{ + "version": "MB Driving FST V1", + "content": { + "meta": { "...": "..." }, + "nodes": [ { "id": 1, "name": "ROOT", "parentId": null }, ... ] + } +} +``` + +返回(200): + +```json +{ + "id": 1, + "version": "MB Driving FST V1", + "updated_time": "2026-02-25T10:00:00+00:00" +} +``` + +错误: + +- `400`:缺少 `version` 或版本在 `version_list` 中不存在 +- `500`:其他内部错误 + +内部实现: + +- 调用 `upsert_fst_stash(db, version=version, content=content)` + +### 2. GET `/fst/stash/` —— 根据版本获取原始 stash 内容 + +对应函数:`get_fst_stash_by_version` + +用途: + +- 按版本直接返回 `fst_stash` 的原始存储内容; +- 适合需要拿到完整 JSON(含 `meta`、`nodes` 等原始结构)的场景。 + +请求示例: + +```http +GET /fst/stash/MB%20Driving%20FST%20V1 +``` + +返回(200): + +```json +{ + "id": 1, + "version": "MB Driving FST V1", + "content": { + "meta": { "...": "..." }, + "nodes": [ ... ] + }, + "updated_time": "2026-02-25T10:00:00+00:00" +} +``` + +错误: + +- `404`:指定版本在 `fst_stash` 中不存在 +- `500`:其他内部错误 + +### 3. GET `/fst/stash/print_tree` —— 基于 stash 构建 FST 树 + +对应函数:`print_tree_from_stash` + +用途: + +- 根据 stash 中的 `nodes` 数组,构建与 `/fst/print_tree` 相同结构的树; +- 方便前端直接复用现有 Tree 渲染逻辑,但数据来源于 `fst_stash`。 + +请求示例: + +```http +GET /fst/stash/print_tree?version=MB%20Driving%20FST%20V1 +``` + +处理逻辑简要: + +1. 使用 `get_fst_stash(db, version)` 读取 stash + - 若不存在或 `content` 为空 → `404` +2. 读取 `content["nodes"]`,要求为数组,否则 `400` +3. 遍历 `nodes`: + - 按 `id` 建立节点映射 + - 将每个节点转换为: + + ```json + { + "id": "", + "label": "", + "level": 0, + "children": [] + } + ``` + +4. 根据 `parentId` 将子节点挂到父节点的 `children` 中 +5. 从所有节点中找出根节点(`parentId` 为空或找不到父节点)作为返回数组 +6. 递归设置 `level`:根节点为 0,子节点在父节点基础上 +1 + +返回(200): + +```json +[ + { + "id": "ROOT", + "label": "ROOT", + "level": 0, + "children": [ + { + "id": "CHILD", + "label": "CHILD", + "level": 1, + "children": [] + } + ] + } +] +``` + +错误: + +- `400`:`version` 缺失,或 `content` 中不含合法的 `nodes` 数组 +- `404`:stash 不存在或内容为空 +- `500`:其他内部错误 + +### 4. POST `/fst/stash/update` —— 按版本更新 stash(对齐 /fst/update 风格) + +对应函数:`upsert_fst_stash_api` + +用途: + +- 参考 `/fst/update` 的风格进行设计; +- 也是对 `fst_stash` 做 UPSERT,只是路径和语义更偏向“编辑当前版本的 stash 内容”; +- 前端可统一通过 `/fst/stash/update` 写入当前版本的 JSON。 +- 该接口默认开启节点级乐观锁校验。 + +请求: + +```json +POST /fst/stash/update +Content-Type: application/json + +{ + "version": "MB Driving FST V1", + "content": { + "meta": { "...": "..." }, + "nodes": [ ... ] + } +} +``` + +返回(200): + +```json +{ + "id": 1, + "version": "MB Driving FST V1", + "updated_time": "2026-02-25T10:00:00+00:00" +} +``` + +错误: + +- `400`: + - 缺少 `version` + - `version` 不在 `version_list` 中(`upsert_fst_stash` 抛出的 `ValueError`) +- `409`: + - 节点级乐观锁冲突(返回 `STASH_NODE_VERSION_CONFLICT` 和冲突节点列表) +- `500`:其他内部错误 + +内部实现: + +- 和 `POST /fst/stash` 一样,最终都调用 `upsert_fst_stash`,只是路由风格与语义更贴近 `/fst/update`。 + +--- + +## 四、与正式 FST 表的关系 + +- 正式 FST 表读取接口: + - `GET /fst/print_tree`:直接从 `fst` 表读取,构建树结构并返回。 +- Stash 相关接口: + - `POST /fst/stash`、`POST /fst/stash/update`:将某个版本的 JSON 写入 `fst_stash` + - `GET /fst/stash/`:按版本拿回原始 JSON + - `GET /fst/stash/print_tree`:基于 stash 的 `nodes` 构建树,结构与 `/fst/print_tree` 相同 + +当前阶段设计原则: + +- **正式 FST 表**:仍然通过 `/fst/update` 等接口直接修改; +- **fst_stash**:作为“导入/草稿/预览版本”的 JSON 存储,方便前端导入导出、预览树结构以及后续一键发布功能的扩展。 + +--- + +## 五、后续新增:Feishu 写入(版本生效) + +本节记录后续新增的“版本生效并同步到飞书多维表格”能力。 + +### 1. 触发接口 + +文件位置:`src/api/versions.py` + +- `POST /versions/stash//activate` +- `POST /versions/fst/stash/activate/` +- `POST /versions//activate` + +说明: + +- 三个路由映射到同一个函数 `activate_version_api`。 +- 请求体可选传入 `content`: + - 传了:直接使用该 JSON 作为飞书写入内容; + - 不传:回退读取 `fst_stash.content`。 +- 若最终拿不到 JSON,返回 `404`。 + +### 2. 执行方式(异步任务) + +`activate_version_api` 不阻塞等待飞书完成,而是: + +1. 创建 `task_id` 并写入内存任务表(`ACTIVATE_TASKS`),初始状态 `queued`; +2. 启动后台线程执行 `_run_activate_task(task_id, version_id, content)`; +3. 立即返回 `202 Accepted`。 + +任务结果查询接口: + +- `GET /versions/stash//activate/result?task_id=...` +- `GET /versions//activate/result?task_id=...` + +如果不传 `task_id`,会返回该版本最近一次任务结果。 + +### 3. 与飞书 SDK 的对接 + +后台线程 `_run_activate_task` 的关键流程: + +1. 将 `version_list.feishu_sync_status` 置为 `syncing`; +2. 调用 `create_bitable_for_version(version_obj.version, content)`; +3. 成功后: + - `feishu_sync_status = synced` + - `status = active` +4. 失败时: + - 回滚后将 `feishu_sync_status = failed` + - 任务状态记为 `failed` 并返回错误信息。 + +### 4. Feishu 写入目标与结构(feishu_bitable_sdk.py) + +文件位置:`src/core/feishu_bitable_sdk.py` + +- 主多维表标题固定为 `Fst_Editor`(常量 `FST_EDITOR_BITABLE_TITLE`)。 +- 目前新增了父文档定位能力: + - 优先使用 `PARENT_NODE_TOKEN`; + - 若为空,则按 `FST_EDITOR_PARENT_DOC_TITLE`(当前值为 `Content generated automatically by bot`)在空间根目录解析父文档; + - 在该父文档下复用或创建 `Fst_Editor` 多维表。 +- 保留“只保留当前版本表”的策略:目标版本表不存在则创建,其他版本表会尝试删除。 +- 当内容可解析为 `nodes`/字段结构时,按业务列写入;否则回退写入 JSON 文本列。 + +### 5. 子文档同步(可开关) + +`create_bitable_for_version` 在主表写入前后会根据开关触发一级节点子文档同步: + +- `ENABLE_WIKI_CHILD_DOC_SYNC` +- `ENABLE_WIKI_CHILD_DOC_SYNC_ASYNC` + +默认配置下为异步触发,不阻塞主流程;结果会体现在返回体 `wiki_child_docs_result` 字段中。 + diff --git a/fst_data_pipeline/apps/root_db_api/README.md b/fst_data_pipeline/apps/root_db_api/README.md new file mode 100644 index 0000000..99c0b84 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/README.md @@ -0,0 +1,1669 @@ +# ROOT DB API + +A core API service for database operations in the FST (File System Tree) data pipeline. + +![Version](https://img.shields.io/badge/version-0.5.0-blue.svg) +![Python](https://img.shields.io/badge/python-3.12+-green.svg) +![Flask](https://img.shields.io/badge/flask-3.1.1+-red.svg) + +## 📝 Description + +ROOT DB API 是基于Flask的REST API服务,为自动驾驶数据管理提供全面的数据库操作功能,包括bag文件、FST节点、项目、标签、主题、几何数据和真值数据的管理。具备完整的Swagger/OpenAPI中文文档和强大的数据管理能力。 + +## 🚀 Features + +- **REST API接口** 提供全面的数据管理功能 +- **Swagger/OpenAPI文档** 提供交互式UI界面 +- **PostgreSQL数据库** 采用SQLAlchemy ORM +- **地理空间支持** 集成PostGIS和Shapely +- **完善测试覆盖** 基于pytest框架 +- **生产环境就绪** 支持Gunicorn部署 + +## 🛠 Tech Stack + +| 组件 | 技术栈 | +|------|--------| +| **Web框架** | Flask 3.1.1+ | +| **数据库** | PostgreSQL + SQLAlchemy ORM | +| **API文档** | Swagger/OpenAPI (Flasgger) | +| **地理空间** | GeoAlchemy2 + Shapely + Folium | +| **部署服务** | Gunicorn | +| **测试框架** | pytest + pytest-cov + pytest-mock | +| **包管理器** | uv | + +## 📋 API Endpoints + +### 🗂️ Bag管理 API (`/api/bags`) + +#### `GET /api/bags/all` +Get all bags grouped by project with complete Pangu and Minerva data. + +**Response Schema:** +```json +{ + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "project_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"}, + "tile_id": {"type": "string"}, + "is_decoded": {"type": "boolean"}, + "pangu_data": {"type": "object"}, + "minerva_data": {"type": "object"} + } + } + } + } +} +``` + +**Example Response:** +```json +{ + "project_dfdi": [ + { + "bag_name": "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "project_id": 1, + "update_time": "2023-08-07T11:56:27Z", + "tile_id": "12345", + "is_decoded": true, + "pangu_data": { + "vehicle": "PL061763", + "datetime": "2023-08-07T11:56:27Z", + "bag_path": "/path/to/bag", + "data_path": "/path/to/data" + }, + "minerva_data": null + } + ] +} +``` + +#### `POST /api/bags/pangu` +Get basic Pangu data by bag names. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "bag_path": {"type": "string"}, + "data_path": {"type": "string"} + } + } + } + } +} +``` + +**Example Request:** +```json +{ + "names": [ + "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "PL061763_event_manual_recording_20230726-150140_0.bag.dir" + ] +} +``` + +**Example Response:** +```json +{ + "data": [ + { + "bag_name": "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "vehicle": "PL061763", + "datetime": "2023-08-07T11:56:27Z", + "bag_path": "/pangu/bags/PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "data_path": "/pangu/data/PL061763_event_ld_gps_event_20230807-115627_0" + } + ] +} +``` + +#### `POST /api/bags/pangu/detail` +Get detailed Pangu information including file paths for all sensors. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "pangu_basic": {"type": "object"}, + "pangu_paths": { + "type": "object", + "properties": { + "lidar_gt_pandar128": {"type": "string"}, + "camera_fisheye_left": {"type": "string"}, + "camera_fisheye_right": {"type": "string"}, + "camera_front_wide": {"type": "string"}, + "raw_gps": {"type": "string"}, + "raw_imu": {"type": "string"}, + "ego_motion": {"type": "string"}, + "calibration": {"type": "string"} + } + } + } + } + } + } +} +``` + +#### `POST /api/bags/topics` +Get topics information by bag names. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "topic_name": {"type": "string"}, + "message_type": {"type": "string"}, + "key_data": {"type": "string"} + } + } + } + } + } + } + } +} +``` + +#### `POST /api/bags/tags` +Get tags associated with specific bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag_name": {"type": "string"}, + "tag_type": {"type": "string"}, + "creator": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } + } + } + } +} +``` + +#### `GET /api/bags/search/pangu` +Search Pangu data with various filter conditions. + +**Query Parameters:** +- `project_id` (optional, integer): Filter by project ID +- `vehicle` (optional, string): Filter by vehicle name +- `start_date` (optional, string): Start date filter (YYYY-MM-DD format) +- `end_date` (optional, string): End date filter (YYYY-MM-DD format) +- `limit` (optional, integer): Limit results (default: 100, max: 1000) +- `offset` (optional, integer): Offset for pagination (default: 0) + +**Example Request:** +``` +GET /api/bags/search/pangu?project_id=1&vehicle=PL061763&limit=50&offset=0 +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "project_id": {"type": "integer"}, + "data_path": {"type": "string"} + } + } + }, + "pagination": { + "type": "object", + "properties": { + "total": {"type": "integer"}, + "limit": {"type": "integer"}, + "offset": {"type": "integer"}, + "has_more": {"type": "boolean"} + } + } + } +} +``` + +### 🌳 FST管理 API (`/api/fst`) + +#### `GET /api/fst/print_tree` +Get complete FST tree structure with hierarchical relationships. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tree_structure": { + "type": "object", + "properties": { + "root_nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "bag_sum": {"type": "integer"}, + "children": {"type": "array"} + } + } + } + } + }, + "statistics": { + "type": "object", + "properties": { + "total_nodes": {"type": "integer"}, + "total_bags": {"type": "integer"}, + "max_depth": {"type": "integer"} + } + } + } +} +``` + +#### `GET /api/fst/all_nodes` +Get all FST nodes with metadata. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "label": {"type": "string"}, + "parent_id": {"type": "integer", "nullable": true} + } + } + } + } +} +``` + +#### `POST /api/fst/bags/nodes` +Get FST node assignments for specific bags with pagination support. + +**Query Parameters:** +- `page` (optional, integer, default: 1): Page number +- `per_page` (optional, integer, default: 20, max: 100): Items per page + +**Request Schema:** +```json +{ + "type": "array", + "items": {"type": "string"}, + "example": ["fst_node1", "fst_node2"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "sts": {"type": "string"} + } + } + }, + "page": {"type": "integer"}, + "per_page": {"type": "integer"}, + "total": {"type": "integer"} + } +} +``` + +#### `GET /api/fst/bags/path/{name}` +Get bags under an FST node and its descendants. + +**Path Parameters:** +- `name` (required, string): The FST node name + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": {"type": "string"} + } + } + } +} +``` + +#### `GET /api/fst/baglist` +Get complete FST tree structure with associated bags including event timing and comments. + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "parent_id": {"type": "integer", "nullable": true}, + "parent_name": {"type": "string", "nullable": true}, + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "event_start_time": {"type": "integer", "nullable": true}, + "event_end_time": {"type": "integer", "nullable": true}, + "comments": {"type": "string", "nullable": true} + } + } + }, + "children": { + "type": "object", + "description": "Child FST nodes with same structure" + } + } + } +} +``` + +**Example Response:** +```json +{ + "1": { + "id": 1, + "name": "highway_scenarios", + "parent_id": null, + "parent_name": null, + "bags": [ + { + "bag_name": "PL061763_highway_20230807-115627_0.bag.dir", + "event_start_time": 1000, + "event_end_time": 2000, + "comments": "Highway driving scenario with lane changes" + } + ], + "children": { + "2": { + "id": 2, + "name": "lane_change", + "parent_id": 1, + "parent_name": "highway_scenarios", + "bags": [], + "children": {} + } + } + } +} +``` + +#### `POST /api/fst/bags/update` +Batch create or update FST-Bag associations. + +**Request Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "required": ["bag_name", "nodes", "start", "end"], + "properties": { + "bag_name": {"type": "string"}, + "nodes": { + "type": "array", + "items": {"type": "string"}, + "description": "Ordered FST node names (parent → child)" + }, + "start": {"type": "integer", "description": "Event start time"}, + "end": {"type": "integer", "description": "Event end time"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "success": {"type": "boolean"}, + "reason": {"type": "string", "nullable": true} + } + } +} +``` + +#### `DELETE /api/fst/{name}` +Delete an FST node by name. + +**Path Parameters:** +- `name` (required, string): FST node name to delete + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "ok": {"type": "boolean"} + } +} +``` + +#### `GET /api/fst/{name}/bags` +Get bags and Pangu details under an FST and its descendants. + +**Path Parameters:** +- `name` (required, string): The FST node name + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bagName": {"type": "string"}, + "decodedDir": {"type": "string"}, + "tosPath": {"type": "string"}, + "mVizUrl": {"type": "string"}, + "mbVizUrl": {"type": "string"}, + "comment": {"type": "string"} + } + } + } + } + } + } + } +} +``` + +#### `GET /api/fst/{name}` +Get specific FST node by name. + +**Path Parameters:** +- `name` (required, string): FST node name + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "bag_sum": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"}, + "children": {"type": "array"}, + "parent_path": {"type": "string"} + } +} +``` + +#### `POST /api/fst/update` +Update FST node information. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "reserved_json": {"type": "object"} + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "updated_node": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } +} +``` + +### 📁 项目管理 API (`/api/projects`) + +#### `GET /api/projects/all` +Get all projects with bag statistics. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"}, + "bag_count": {"type": "integer"}, + "decoded_count": {"type": "integer"}, + "active_count": {"type": "integer"} + } + } + } + } +} +``` + +### 🏷️ 标签管理 API (`/api/tags`) + +#### `GET /api/tags/all` +Get all available tags. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "creator": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"}, + "comments": {"type": "string"}, + "is_deleted": {"type": "boolean"} + } + } + } + } +} +``` + +#### `GET /api/tags/bags` +Get tags with associated bag counts. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tag_statistics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag_name": {"type": "string"}, + "tag_type": {"type": "string"}, + "bag_count": {"type": "integer"}, + "creator": {"type": "string"} + } + } + } + } +} +``` + +#### `GET /api/tags/bags` +Get bags by tag names with intersection or union logic. + +**Query Parameters:** +- `tags` (required, string): Comma-separated tag names (e.g., "A,B,C") +- `op` (optional, string): Operation type - "and" for intersection, "or" for union (default: "and") + +**Example Request:** +``` +GET /api/tags/bags?tags=driving,city&op=and +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"} + } + } + } + } +} +``` + +#### `GET /api/tags/creators/{creator}` +Get tags created by a specific creator. + +**Path Parameters:** +- `creator` (required, string): Creator identifier (username or email) + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +#### `POST /api/tags/{tag_name}/bags` +Batch apply tags to bags with creator tracking. + +**Path Parameters:** +- `tag_name` (required, string): Tag name + +**Query Parameters:** +- `creator` (optional, string, default: "system"): Creator identifier + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tag_name": {"type": "string"}, + "creator": {"type": "string"}, + "total": {"type": "integer"}, + "succeeded": {"type": "integer"}, + "skipped": {"type": "integer"}, + "failed": {"type": "integer"}, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "status": {"type": "string", "enum": ["success", "skipped", "failed"]}, + "message": {"type": "string"} + } + } + } + } +} +``` + +### 📖 主题管理 API (`/api/topics`) + +#### `GET /api/topics/all` +Get all available ROS topics. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "key_data": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } +} +``` + +#### `GET /api/topics/bags/{topic_name}` +Get bags containing specific topic. + +**Path Parameters:** +- `topic_name` (required, string): Topic name (URL encoded) + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "topic_name": {"type": "string"}, + "topic_info": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "key_data": {"type": "string"} + } + }, + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "project_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } +} +``` + +### 🗺️ 几何数据管理 API (`/api/geometry`) + +#### `POST /api/geometry/` +Process geospatial geometry data and perform spatial operations. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["rosbag_names"], + "properties": { + "rosbag_names": { + "type": "array", + "items": {"type": "string"} + }, + "operation": { + "type": "string", + "enum": ["get_trajectory", "check_overlap", "get_bounds"], + "default": "get_trajectory" + }, + "downsample_factor": {"type": "integer", "default": 1} + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rosbag_name": {"type": "string"}, + "trajectory": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["LineString", "MultiPoint"]}, + "coordinates": {"type": "array"} + } + }, + "is_overlapped": {"type": "boolean"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } +} +``` + +### 🎯 真值数据管理 API (`/api/gt`) + +#### `GET /api/gt/types` +Get all available Ground Truth data types. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "gt_types": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "path": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"}, + "comment": {"type": "string"} + } + } + } + } +} +``` + +#### `GET /api/gt/{gt_name}/bags` +Get bags associated with specific Ground Truth data. + +**Path Parameters:** +- `gt_name` (required, string): Ground Truth name + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "gt_name": {"type": "string"}, + "gt_info": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "path": {"type": "string"}, + "comment": {"type": "string"} + } + }, + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "project_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"}, + "comment": {"type": "string"} + } + } + } + } +} +``` + +#### `POST /api/bags/minerva` +Get Minerva data by session IDs. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["session_ids"], + "properties": { + "session_ids": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Example Request:** +```json +{ + "session_ids": ["session_123", "session_456"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "vin": {"type": "string"}, + "start_ts": {"type": "number"}, + "end_ts": {"type": "number"}, + "platform": {"type": "string"}, + "path": {"type": "string"}, + "converted_path": {"type": "string"}, + "gt_path": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "length": {"type": "number"}, + "reserved_json": {"type": "object"} + } + } + } +} +``` + +#### `POST /api/bags/minerva/detail` +Get detailed Minerva information by bag names. + +**Request Schema:** +```json +{ + "type": "array", + "items": {"type": "string"}, + "example": ["bag1.bag", "bag2.bag"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "vin": {"type": "string"}, + "platform": {"type": "string"}, + "path": {"type": "string"}, + "converted_path": {"type": "string"}, + "gt_path": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"} + } + } +} +``` + +#### `POST /api/bags/life_cycle` +Get lifecycle information for specific bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "collect_time": {"type": "string", "format": "date-time"}, + "clone2dev_time": {"type": "string", "format": "date-time"}, + "decode_time": {"type": "string", "format": "date-time"}, + "mining_time": {"type": "string", "format": "date-time"}, + "auto_annotate_time": {"type": "string", "format": "date-time"}, + "manual_annotate_time": {"type": "string", "format": "date-time"}, + "fst_index_time": {"type": "string", "format": "date-time"} + } + } +} +``` + +#### `GET /api/bags/joined` +Get related bags before/after a specified bag by datetime. + +**Query Parameters:** +- `bag_name` (required, string): Name of the central bag +- `before` (optional, integer, default: 5): How many bags to return before +- `after` (optional, integer, default: 5): How many bags to return after + +**Response Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "bag_path": {"type": "string"}, + "data_path": {"type": "string"} + } + } +} +``` + +#### `POST /api/bags/joined/create` +Create a merged parent bag from sub-bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "joined_id": {"type": "integer"}, + "joined_name": {"type": "string"} + } +} +``` + +#### `POST /api/bags/joined/query` +Query existing merged relationships by bag names. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"} + }, + "example": { + "parent_merged": ["child1", "child2", "child3"], + "parent2_merged": ["child4", "child5"] + } +} +``` + +#### `POST /api/bags/joined/delete` +Delete merged parent bags and restore child bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "deleted_parents": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +#### `GET /api/bags/version` +Query bags by software/hardware version patterns. + +**Query Parameters:** +- `sw` (optional, string): Software version pattern (supports * wildcards) +- `hw` (optional, string): Hardware version pattern (supports * wildcards) + +**Example Request:** +``` +GET /api/bags/version?sw=1.2.*&hw=*rev3* +``` + +**Response Schema:** +```json +{ + "type": "array", + "items": {"type": "string"}, + "example": ["bag1.bag", "bag2.bag"] +} +``` + +### 🔄 重计算管理 API (`/api/recompute`) + +#### `POST /api/recompute/versions` +Get recompute versions for bags (batch). + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "List of bag names" + } + } +} +``` + +**Example Request:** +```json +{ + "bag_names": ["bag1.bag", "bag2.bag", "bag3.bag"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"} + }, + "example": { + "bag1.bag": ["v1.0", "v1.1", "v2.0"], + "bag2.bag": ["v1.0", "v2.0"], + "bag3.bag": [] + } +} +``` + +#### `POST /api/recompute/storage` +Get storage paths for bags and versions (batch). + +**Request Schema:** +```json +{ + "type": "object", + "required": ["requests"], + "properties": { + "requests": { + "type": "array", + "items": { + "type": "object", + "required": ["bag_name", "recompute_version"], + "properties": { + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"} + } + } + } + } +} +``` + +**Example Request:** +```json +{ + "requests": [ + {"bag_name": "bag1.bag", "recompute_version": "v1.0"}, + {"bag_name": "bag2.bag", "recompute_version": "v1.1"} + ] +} +``` + +**Response Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"}, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "result_id": {"type": "integer"}, + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"}, + "result_type": {"type": "string"}, + "storage_path": {"type": "string"}, + "status": {"type": "string"}, + "created_time": {"type": "string", "format": "date-time"}, + "reserved_json": {"type": "object"} + } + } + } + } + } +} +``` + +## 🚀 Quick Start + +### Prerequisites + +- Python 3.12+ +- PostgreSQL database +- uv package manager + +### Installation + +1. **Clone the repository** + ```bash + cd /home/cheng/Codes/fst_data_pipeline/fst_data_pipeline/apps/root_db_api + ``` + +2. **Create virtual environment** + ```bash + uv venv + source .venv/bin/activate + ``` + +3. **Install dependencies** + ```bash + uv sync + ``` + +4. **Set up environment variables** + ```bash + cp .env.example .env + # Edit .env with your database configurations + ``` + +5. **Initialize database** + ```bash + # Run database migrations or setup scripts + python -m alembic upgrade head + ``` + +### Running the Application + +#### Development Mode +```bash +python src/app.py +``` + +#### Production Mode +```bash +gunicorn --bind 0.0.0.0:5232 --workers 4 src.app:app +``` + +API服务访问地址: +- **API基础URL**: `http://localhost:5232/api` +- **Swagger UI文档**: `http://localhost:5232/apidocs/` + +## 🔧 Configuration + +### Environment Variables + +Create a `.env` file with the following variables: + +```env +# Database Configuration +DATABASE_URL=postgresql://username:password@localhost:5432/dbname + +# Flask Configuration +FLASK_ENV=development +FLASK_DEBUG=True + +# API Configuration +API_VERSION=0.5.0 + +# Feishu sync switch for version activation (backend) +# Default is off. Set to true only when you want to sync to Feishu. +ENABLE_FEISHU_SYNC=false +``` + +### Database Configuration + +The application uses PostgreSQL with SQLAlchemy. Key models include: +- **Project**: Project information +- **Bag**: Bag file metadata +- **FST**: File system tree nodes +- **Tag**: Data tagging system +- **Topic**: Topic classification +- **GT**: Ground truth data + +## 🧪 Testing + +Run the test suite: + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Run specific test module +pytest src/test/api/test_bags.py -v +``` + +## 📚 API Documentation + +### Swagger/OpenAPI 中文文档 + +交互式中文API文档,当服务器运行时可通过 `/apidocs/` 访问。 + +**主要功能:** +- 🌐 **完整中文界面** - 所有API标签、描述都已中文化 +- 📋 **8大功能模块** - Bag管理、标签管理、FST管理、项目管理、主题管理、几何数据管理、真值数据管理、重计算管理 +- 🔄 **实时测试** - 可直接在文档中测试API接口 +- 📝 **详细示例** - 每个接口都包含完整的请求/响应示例 + +### Example API Calls + +#### Get All Bags +```bash +curl -X GET "http://localhost:5232/api/bags/all" \ + -H "accept: application/json" +``` + +#### Get Pangu Data by Names +```bash +curl -X POST "http://localhost:5232/api/bags/pangu" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "names": [ + "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir" + ] + }' +``` + +#### Search Pangu Data +```bash +curl -X GET "http://localhost:5232/api/bags/search/pangu?project_id=1&limit=10" \ + -H "accept: application/json" +``` + +#### Get FST Tree +```bash +curl -X GET "http://localhost:5232/api/fst/print_tree" \ + -H "accept: application/json" +``` + +#### Update FST Node +```bash +curl -X POST "http://localhost:5232/api/fst/update" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "highway_scenarios", + "description": "Updated highway driving scenarios", + "parent_id": null + }' +``` + +## 🔒 Security + +- Input validation with Pydantic models +- SQL injection protection via SQLAlchemy ORM +- Error handling and logging +- Rate limiting (configure as needed) + +## 📈 Performance + +- Database connection pooling +- Optimized SQL queries +- Configurable Gunicorn workers +- Efficient SQLAlchemy ORM queries + +## 🚀 Deployment + +### Docker Deployment +```bash +# Build Docker image +docker build -t root-db-api . + +# Run container +docker run -p 5232:5232 --env-file .env root-db-api +``` + +### Production Checklist +- [ ] Configure environment variables +- [ ] Set up PostgreSQL database +- [ ] Set up reverse proxy (nginx) +- [ ] Configure logging +- [ ] Set up monitoring +- [ ] Configure SSL/TLS +- [ ] Set up database backups + +## 📝 Development + +### Project Structure +``` +src/ +├── api/ # API接口模块 +│ ├── bags.py # Bag管理API +│ ├── fst.py # FST管理API +│ ├── projects.py # 项目管理API +│ ├── tags.py # 标签管理API +│ ├── topics.py # 主题管理API +│ ├── geometry.py # 几何数据管理API +│ ├── recompute.py # 重计算管理API +│ └── gt.py # 真值数据管理API +├── core/ # Core business logic +│ ├── models.py # Database models +│ └── service.py # Business logic services +├── db/ # Database layer +│ └── connection.py # Database connection +├── test/ # Test files +└── app.py # Application entry point +``` + +### Contributing + +1. Fork the repository +2. Create a feature branch +3. Write tests for new features +4. Ensure all tests pass +5. Submit a pull request + +## 📊 Monitoring + +### Health Check Endpoint +```bash +curl http://localhost:5232/ +# Returns: "Hello World!" +``` + +### Metrics +- Prometheus metrics integration available +- Monitor API response times +- Track database query performance +- Monitor database connection pool usage + +## 🆘 Troubleshooting + +### Common Issues + +1. **Database Connection Error** + - Check PostgreSQL service status + - Verify DATABASE_URL configuration + - Ensure database exists and is accessible + +2. **Import Errors** + - Ensure virtual environment is activated + - Run `uv sync` to install dependencies + +3. **Port Already in Use** + - Change port in app.py or use environment variable + - Kill existing processes on port 5232 + +4. **Geospatial Query Errors** + - Ensure PostGIS extension is installed + - Check spatial data formats and coordinates + +## 📞 Support + +- **Author**: Cheng Li +- **Email**: tbd@tbd.com +- **Version**: 0.5.0 + +## 📄 License + +[Add your license information here] + +--- + +**Happy Coding! 🚀** \ No newline at end of file diff --git a/fst_data_pipeline/apps/root_db_api/__init__.py b/fst_data_pipeline/apps/root_db_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/root_db_api/dev/db/20260309_add_feishu_sync_status.sql b/fst_data_pipeline/apps/root_db_api/dev/db/20260309_add_feishu_sync_status.sql new file mode 100644 index 0000000..79d0c7e --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/dev/db/20260309_add_feishu_sync_status.sql @@ -0,0 +1,4 @@ +ALTER TABLE version_list +ADD COLUMN IF NOT EXISTS feishu_sync_status VARCHAR(50) NOT NULL DEFAULT 'unsynced'; + +COMMENT ON COLUMN version_list.feishu_sync_status IS 'Feishu sync status: unsynced/synced'; diff --git a/fst_data_pipeline/apps/root_db_api/dev/db/init.sql b/fst_data_pipeline/apps/root_db_api/dev/db/init.sql new file mode 100644 index 0000000..69ce5b5 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/dev/db/init.sql @@ -0,0 +1,702 @@ +-- ====================================================== +-- 0 扩展 +-- ====================================================== +CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +COMMENT ON EXTENSION postgis IS 'PostGIS 空间数据库扩展'; +COMMENT ON EXTENSION "uuid-ossp" IS 'UUID 生成扩展'; + +-- ====================================================== +-- ① 基础准备 +-- ====================================================== +CREATE TABLE IF NOT EXISTS project ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +COMMENT ON TABLE project IS '数据来源 1:DFDI 2:CFDI'; +COMMENT ON COLUMN project.id IS '项目自增主键'; +COMMENT ON COLUMN project.name IS '项目名称,全局唯一'; +COMMENT ON COLUMN project.update_time IS '最近一次更新时间'; + +-- ====================================================== +-- ② 字典 & 主数据(无外部依赖) +-- ====================================================== +CREATE TABLE IF NOT EXISTS drive_mode ( + id SERIAL PRIMARY KEY, + type VARCHAR(100) NOT NULL, + sub_type VARCHAR(100), + reserved_json JSONB, + comment TEXT, + UNIQUE (type, sub_type) +); +COMMENT ON TABLE drive_mode IS '驾驶模式字典表'; +COMMENT ON COLUMN drive_mode.id IS '主键'; +COMMENT ON COLUMN drive_mode.type IS '驾驶模式大类,AD Drive/AD PARK(APA)/AD PARK(MPA)'; +COMMENT ON COLUMN drive_mode.sub_type IS '驾驶模式子类,如ACC/CP/NP'; +COMMENT ON COLUMN drive_mode.reserved_json IS '预留扩展 JSON'; +COMMENT ON COLUMN drive_mode.comment IS '备注'; + +CREATE TABLE IF NOT EXISTS topic_list ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + type VARCHAR(255), + key_data TEXT, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + reserved_json JSONB +); +COMMENT ON TABLE topic_list IS 'Topic表'; +COMMENT ON COLUMN topic_list.id IS '主键'; +COMMENT ON COLUMN topic_list.name IS 'Topic 名称,如 /lidar/points'; +COMMENT ON COLUMN topic_list.type IS 'Topic 类型,如 sensor_msgs/PointCloud2'; +COMMENT ON COLUMN topic_list.key_data IS '关键数据摘要'; +COMMENT ON COLUMN topic_list.update_time IS '更新时间'; +COMMENT ON COLUMN topic_list.reserved_json IS '预留扩展 JSON'; + +CREATE TABLE IF NOT EXISTS reserved_tag_list ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + type VARCHAR(50), + creator VARCHAR(255), + comments TEXT, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT FALSE, + reserved_json JSONB +); +COMMENT ON TABLE reserved_tag_list IS '预留标签字典表'; +COMMENT ON COLUMN reserved_tag_list.id IS '主键'; +COMMENT ON COLUMN reserved_tag_list.name IS '标签名称'; +COMMENT ON COLUMN reserved_tag_list.type IS '标签分类'; +COMMENT ON COLUMN reserved_tag_list.creator IS '创建人'; +COMMENT ON COLUMN reserved_tag_list.comments IS '备注'; +COMMENT ON COLUMN reserved_tag_list.update_time IS '更新时间'; +COMMENT ON COLUMN reserved_tag_list.is_deleted IS '逻辑删除标记'; +COMMENT ON COLUMN reserved_tag_list.reserved_json IS '预留扩展 JSON'; + +CREATE TABLE IF NOT EXISTS gt_meta ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + type VARCHAR(100) NOT NULL, + path TEXT NOT NULL, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + reserved_json JSONB, + comment TEXT, + CONSTRAINT gt_meta_name_type_unique UNIQUE (name, type) +); +COMMENT ON TABLE gt_meta IS '真值元数据表'; +COMMENT ON COLUMN gt_meta.id IS '主键'; +COMMENT ON COLUMN gt_meta.name IS '真值名称'; +COMMENT ON COLUMN gt_meta.type IS '真值类型,如 3DBox/Lane'; +COMMENT ON COLUMN gt_meta.path IS '真值文件存储路径'; +COMMENT ON COLUMN gt_meta.update_time IS '更新时间'; +COMMENT ON COLUMN gt_meta.reserved_json IS '预留扩展 JSON'; +COMMENT ON COLUMN gt_meta.comment IS '备注'; + +CREATE TABLE IF NOT EXISTS version_list ( + id SERIAL PRIMARY KEY, + version VARCHAR(255) NOT NULL UNIQUE, + type VARCHAR(50), + description TEXT, + release_date TIMESTAMPTZ, + created_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(50) NOT NULL DEFAULT 'available', + feishu_sync_status VARCHAR(50) NOT NULL DEFAULT 'unsynced', + reserved_json JSONB +); + +-- ====================================================== +-- ③ 核心业务表(依赖 ①②) +-- ====================================================== +CREATE TABLE IF NOT EXISTS bag_list ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + project_id INT, + tile_id VARCHAR(255), + is_decoded BOOLEAN DEFAULT FALSE, + is_deleted BOOLEAN DEFAULT FALSE, + od_annotated INT, + ld_annotated INT, + fst_indexed BOOLEAN DEFAULT FALSE, + is_active_data BOOLEAN DEFAULT TRUE, + reserved_str VARCHAR(255), + reserved_json JSONB, + drive_mode SMALLINT, + sw_version VARCHAR(100), + hw_version VARCHAR(100), + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE, + FOREIGN KEY (drive_mode) REFERENCES drive_mode(id) ON DELETE SET NULL +); +COMMENT ON TABLE bag_list IS 'Rosbag 主表'; +COMMENT ON COLUMN bag_list.id IS '主键'; +COMMENT ON COLUMN bag_list.name IS 'Bag 文件名称,全局唯一'; +COMMENT ON COLUMN bag_list.update_time IS '更新时间'; +COMMENT ON COLUMN bag_list.project_id IS '数据来源'; +COMMENT ON COLUMN bag_list.tile_id IS '瓦片 ID'; +COMMENT ON COLUMN bag_list.is_decoded IS '是否已解码'; +COMMENT ON COLUMN bag_list.is_deleted IS '逻辑删除'; +COMMENT ON COLUMN bag_list.od_annotated IS '是否目标检测标注'; +COMMENT ON COLUMN bag_list.ld_annotated IS '是否车道线标注'; +COMMENT ON COLUMN bag_list.fst_indexed IS '是否已建立 FST 索引'; +COMMENT ON COLUMN bag_list.is_active_data IS '是否为活跃数据'; +COMMENT ON COLUMN bag_list.reserved_str IS '预留字符串'; +COMMENT ON COLUMN bag_list.reserved_json IS '预留扩展 JSON'; +COMMENT ON COLUMN bag_list.drive_mode IS '驾驶模式字典 ID'; +COMMENT ON COLUMN bag_list.sw_version IS '软件版本号(Software Version)'; +COMMENT ON COLUMN bag_list.hw_version IS '硬件版本号(Hardware Version)'; + +CREATE TABLE IF NOT EXISTS bag_lifecycle ( + id SERIAL PRIMARY KEY, + bag_name VARCHAR(255) NOT NULL UNIQUE, + collect_time TIMESTAMPTZ, + clone2dev_time TIMESTAMPTZ, + decode_time TIMESTAMPTZ, + mining_time TIMESTAMPTZ, + auto_annotate_time TIMESTAMPTZ, + manual_annotate_time TIMESTAMPTZ, + fst_index_time TIMESTAMPTZ, + update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (bag_name) REFERENCES bag_list(name) ON DELETE CASCADE +); +COMMENT ON TABLE bag_lifecycle IS 'Bag 生命周期时间表'; +COMMENT ON COLUMN bag_lifecycle.id IS '主键'; +COMMENT ON COLUMN bag_lifecycle.bag_name IS 'Bag 名称,关联 bag_list.name'; +COMMENT ON COLUMN bag_lifecycle.collect_time IS '采集完成时间'; +COMMENT ON COLUMN bag_lifecycle.clone2dev_time IS '拷贝到bucket时间'; +COMMENT ON COLUMN bag_lifecycle.decode_time IS '解码完成时间'; +COMMENT ON COLUMN bag_lifecycle.mining_time IS '数据挖掘完成时间'; +COMMENT ON COLUMN bag_lifecycle.auto_annotate_time IS '自动标注完成时间'; +COMMENT ON COLUMN bag_lifecycle.manual_annotate_time IS '人工标注完成时间'; +COMMENT ON COLUMN bag_lifecycle.fst_index_time IS 'FST 索引完成时间'; +COMMENT ON COLUMN bag_lifecycle.update_time IS '记录更新时间'; + +CREATE TABLE IF NOT EXISTS main_pangu ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + vehicle VARCHAR(255), + datetime TIMESTAMPTZ, + bag_path TEXT, + data_path TEXT, + reserved_str VARCHAR(255), + reserved_json JSONB +); +COMMENT ON TABLE main_pangu IS 'Pangu 主表(原始数据)'; +COMMENT ON COLUMN main_pangu.id IS '主键'; +COMMENT ON COLUMN main_pangu.name IS '名称,全局唯一'; +COMMENT ON COLUMN main_pangu.vehicle IS '车辆编号'; +COMMENT ON COLUMN main_pangu.datetime IS 'rosbag的生成时间'; +COMMENT ON COLUMN main_pangu.bag_path IS '原始 Bag 路径'; +COMMENT ON COLUMN main_pangu.data_path IS '解析后数据路径'; +COMMENT ON COLUMN main_pangu.reserved_str IS '预留字符串'; +COMMENT ON COLUMN main_pangu.reserved_json IS '预留扩展 JSON'; + +CREATE TABLE IF NOT EXISTS joined_pangu ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + data_path TEXT, + reserved_str VARCHAR(255), + reserved_json JSONB +); +COMMENT ON TABLE joined_pangu IS 'Pangu rosbag融合表'; +COMMENT ON COLUMN joined_pangu.id IS '主键'; +COMMENT ON COLUMN joined_pangu.name IS '融合后名称'; +COMMENT ON COLUMN joined_pangu.data_path IS '融合后数据路径'; +COMMENT ON COLUMN joined_pangu.reserved_str IS '预留字符串'; +COMMENT ON COLUMN joined_pangu.reserved_json IS '预留扩展 JSON'; + +CREATE TABLE IF NOT EXISTS joined_bags ( + id SERIAL PRIMARY KEY, + parent_id INT NOT NULL, + child_id INT NOT NULL UNIQUE, + reserved_str VARCHAR(255), + reserved_json JSONB +); +COMMENT ON TABLE joined_bags IS 'Bag 融合关系表'; +COMMENT ON COLUMN joined_bags.id IS '主键'; +COMMENT ON COLUMN joined_bags.parent_id IS '父 Bag ID'; +COMMENT ON COLUMN joined_bags.child_id IS '子 Bag ID,唯一'; +COMMENT ON COLUMN joined_bags.reserved_str IS '预留字符串'; +COMMENT ON COLUMN joined_bags.reserved_json IS '预留扩展 JSON'; + +CREATE TABLE IF NOT EXISTS main_minerva ( + id SERIAL PRIMARY KEY, + session_id VARCHAR(255) UNIQUE, + start_ts TIMESTAMPTZ, + end_ts TIMESTAMPTZ, + length INT, + datetime TIMESTAMPTZ, + vin VARCHAR(255), + platform VARCHAR(255), + mapped BOOLEAN DEFAULT FALSE, + path TEXT, + converted_path TEXT, + gt_path TEXT, + reserved_json JSONB +); +COMMENT ON TABLE main_minerva IS 'Minerva 主表'; +COMMENT ON COLUMN main_minerva.id IS '主键'; +COMMENT ON COLUMN main_minerva.session_id IS '会话 ID,唯一'; +COMMENT ON COLUMN main_minerva.start_ts IS '开始时间戳'; +COMMENT ON COLUMN main_minerva.end_ts IS '结束时间戳'; +COMMENT ON COLUMN main_minerva.length IS '时长(秒)'; +COMMENT ON COLUMN main_minerva.datetime IS '数据日期'; +COMMENT ON COLUMN main_minerva.vin IS '车辆识别码'; +COMMENT ON COLUMN main_minerva.platform IS '平台名称'; +COMMENT ON COLUMN main_minerva.mapped IS '是否已映射'; +COMMENT ON COLUMN main_minerva.path IS '原始数据路径'; +COMMENT ON COLUMN main_minerva.converted_path IS '转换后路径'; +COMMENT ON COLUMN main_minerva.gt_path IS '真值路径'; +COMMENT ON COLUMN main_minerva.reserved_json IS '预留扩展 JSON'; + +-- ====================================================== +-- ④ 子表 & 关联表(依赖 bag_list.id) +-- ====================================================== +CREATE TABLE IF NOT EXISTS secondary_pangu ( + id SERIAL PRIMARY KEY, + bag_id INT NOT NULL UNIQUE, + lidar_gt_pandar128 TEXT, + object_lidar_gt_pandar128_manual TEXT, + lidar_fd_multi_scan_raw TEXT, + camera_fisheye_left TEXT, + camera_fisheye_right TEXT, + camera_front_wide TEXT, + raw_gps TEXT, + raw_imu TEXT, + ego_motion TEXT, + vehicle_wheel TEXT, + calibration TEXT, + sdmap TEXT, + reserved_json JSONB, + FOREIGN KEY (bag_id) REFERENCES bag_list(id) ON DELETE CASCADE +); +COMMENT ON TABLE secondary_pangu IS 'Pangu 二级数据路径表'; +COMMENT ON COLUMN secondary_pangu.id IS '主键'; +COMMENT ON COLUMN secondary_pangu.bag_id IS '关联 bag_list.id'; +COMMENT ON COLUMN secondary_pangu.lidar_gt_pandar128 IS '激光真值路径'; +COMMENT ON COLUMN secondary_pangu.object_lidar_gt_pandar128_manual IS '人工真值路径'; +COMMENT ON COLUMN secondary_pangu.lidar_fd_multi_scan_raw IS '原始雷达路径'; +COMMENT ON COLUMN secondary_pangu.camera_fisheye_left IS '左鱼眼相机路径'; +COMMENT ON COLUMN secondary_pangu.camera_fisheye_right IS '右鱼眼相机路径'; +COMMENT ON COLUMN secondary_pangu.camera_front_wide IS '前广角相机路径'; +COMMENT ON COLUMN secondary_pangu.raw_gps IS '原始 GPS 路径'; +COMMENT ON COLUMN secondary_pangu.raw_imu IS '原始 IMU 路径'; +COMMENT ON COLUMN secondary_pangu.ego_motion IS '自车运动路径'; +COMMENT ON COLUMN secondary_pangu.vehicle_wheel IS '轮速路径'; +COMMENT ON COLUMN secondary_pangu.calibration IS '标定文件路径'; +COMMENT ON COLUMN secondary_pangu.sdmap IS 'SD 地图路径或地图版本标识'; +COMMENT ON COLUMN secondary_pangu.reserved_json IS '预留扩展 JSON'; + +CREATE TABLE IF NOT EXISTS secondary_minerva ( + id SERIAL PRIMARY KEY, + bag_id INT NOT NULL UNIQUE, + lidar_gt_top_p128 TEXT, + lidar_parking_gt_front_p128 TEXT, + lidar_parking_gt_left_p128 TEXT, + lidar_parking_gt_right_p128 TEXT, + lidar_parking_gt_rear_p128 TEXT, + camera_fisheye_left_200fov TEXT, + camera_fisheye_right_200fov TEXT, + camera_fisheye_rear_200fov TEXT, + camera_fisheye_front_200fov TEXT, + camera_front_wide_120fov TEXT, + camera_front_tele_30fov TEXT, + camera_rear_right_70fov TEXT, + camera_rear_left_70fov TEXT, + raw_gps TEXT, + raw_imu TEXT, + ego_motion TEXT, + calibration TEXT, + rig TEXT, + fst_new TEXT, + fst_old TEXT, + comments TEXT, + reserved_json JSONB, + FOREIGN KEY (bag_id) REFERENCES bag_list(id) ON DELETE CASCADE +); +COMMENT ON TABLE secondary_minerva IS 'Minerva 二级数据路径表'; +COMMENT ON COLUMN secondary_minerva.id IS '主键'; +COMMENT ON COLUMN secondary_minerva.bag_id IS '关联 bag_list.id'; +COMMENT ON COLUMN secondary_minerva.lidar_gt_top_p128 IS '顶部Lidar真值路径'; +COMMENT ON COLUMN secondary_minerva.lidar_parking_gt_front_p128 IS '前停车Lidar真值路径'; +COMMENT ON COLUMN secondary_minerva.lidar_parking_gt_left_p128 IS '左停车Lidar真值路径'; +COMMENT ON COLUMN secondary_minerva.lidar_parking_gt_right_p128 IS '右停车Lidar真值路径'; +COMMENT ON COLUMN secondary_minerva.lidar_parking_gt_rear_p128 IS '后停车Lidar真值路径'; +COMMENT ON COLUMN secondary_minerva.camera_fisheye_left_200fov IS '左 200° 鱼眼路径'; +COMMENT ON COLUMN secondary_minerva.camera_fisheye_right_200fov IS '右 200° 鱼眼路径'; +COMMENT ON COLUMN secondary_minerva.camera_fisheye_rear_200fov IS '后 200° 鱼眼路径'; +COMMENT ON COLUMN secondary_minerva.camera_fisheye_front_200fov IS '前 200° 鱼眼路径'; +COMMENT ON COLUMN secondary_minerva.camera_front_wide_120fov IS '前 120° 广角路径'; +COMMENT ON COLUMN secondary_minerva.camera_front_tele_30fov IS '前 30° 长焦路径'; +COMMENT ON COLUMN secondary_minerva.camera_rear_right_70fov IS '右后 70° 相机路径'; +COMMENT ON COLUMN secondary_minerva.camera_rear_left_70fov IS '左后 70° 相机路径'; +COMMENT ON COLUMN secondary_minerva.raw_gps IS '原始 GPS 路径'; +COMMENT ON COLUMN secondary_minerva.raw_imu IS '原始 IMU 路径'; +COMMENT ON COLUMN secondary_minerva.ego_motion IS '自车运动路径'; +COMMENT ON COLUMN secondary_minerva.calibration IS '标定文件路径'; +COMMENT ON COLUMN secondary_minerva.rig IS 'rig 文件路径'; +COMMENT ON COLUMN secondary_minerva.fst_new IS '新 FST 路径'; +COMMENT ON COLUMN secondary_minerva.fst_old IS '旧 FST 路径'; +COMMENT ON COLUMN secondary_minerva.comments IS '备注'; +COMMENT ON COLUMN secondary_minerva.reserved_json IS '预留扩展 JSON'; + +CREATE TABLE IF NOT EXISTS fst ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + parent_id INT, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + reserved_json JSONB, + bag_sum INT DEFAULT 0, + FOREIGN KEY (parent_id) REFERENCES fst(id) ON DELETE CASCADE +); +COMMENT ON TABLE fst IS 'FST 节点表'; +COMMENT ON COLUMN fst.id IS '主键'; +COMMENT ON COLUMN fst.name IS '节点名称'; +COMMENT ON COLUMN fst.parent_id IS '父节点 ID'; +COMMENT ON COLUMN fst.update_time IS '更新时间'; +COMMENT ON COLUMN fst.reserved_json IS '预留扩展 JSON'; +COMMENT ON COLUMN fst.bag_sum IS '子树 Bag 数量'; + +CREATE TABLE IF NOT EXISTS geometry_info ( + id SERIAL PRIMARY KEY, + rosbag_name VARCHAR(255) NOT NULL, + gnss_downsampled_points geometry[], + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_overlapped BOOLEAN DEFAULT FALSE +); +COMMENT ON TABLE geometry_info IS 'Bag 几何信息表(GNSS 轨迹)'; +COMMENT ON COLUMN geometry_info.id IS '主键'; +COMMENT ON COLUMN geometry_info.rosbag_name IS 'Rosbag 名称'; +COMMENT ON COLUMN geometry_info.gnss_downsampled_points IS '降采样 GNSS 轨迹点'; +COMMENT ON COLUMN geometry_info.update_time IS '更新时间'; +COMMENT ON COLUMN geometry_info.is_overlapped IS '是否与其他 Bag 重叠'; + +CREATE TABLE IF NOT EXISTS bag_topic ( + id SERIAL PRIMARY KEY, + bag_id INT NOT NULL, + topic_id INT NOT NULL, + FOREIGN KEY (bag_id) REFERENCES bag_list(id) ON DELETE CASCADE, + FOREIGN KEY (topic_id) REFERENCES topic_list(id) ON DELETE CASCADE +); +COMMENT ON TABLE bag_topic IS 'Bag-Topic 多对多关系'; +COMMENT ON COLUMN bag_topic.id IS '主键'; +COMMENT ON COLUMN bag_topic.bag_id IS 'Bag ID'; +COMMENT ON COLUMN bag_topic.topic_id IS 'Topic ID'; + +CREATE TABLE IF NOT EXISTS bag_reserved_tag ( + id SERIAL PRIMARY KEY, + bag_id INT NOT NULL, + tag_id INT NOT NULL, + FOREIGN KEY (bag_id) REFERENCES bag_list(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES reserved_tag_list(id) ON DELETE CASCADE +); +COMMENT ON TABLE bag_reserved_tag IS 'Bag-预留标签多对多关系'; +COMMENT ON COLUMN bag_reserved_tag.id IS '主键'; +COMMENT ON COLUMN bag_reserved_tag.bag_id IS 'Bag ID'; +COMMENT ON COLUMN bag_reserved_tag.tag_id IS '标签 ID'; + +CREATE TABLE IF NOT EXISTS bag_gt ( + id SERIAL PRIMARY KEY, + gt_id INT NOT NULL, + bag_id INT NOT NULL, + comment TEXT, + UNIQUE (gt_id, bag_id), + FOREIGN KEY (gt_id) REFERENCES gt_meta(id) ON DELETE CASCADE, + FOREIGN KEY (bag_id) REFERENCES bag_list(id) ON DELETE CASCADE +); +COMMENT ON TABLE bag_gt IS 'Bag-真值多对多关系'; +COMMENT ON COLUMN bag_gt.id IS '主键'; +COMMENT ON COLUMN bag_gt.gt_id IS '真值元数据 ID'; +COMMENT ON COLUMN bag_gt.bag_id IS 'Bag ID'; +COMMENT ON COLUMN bag_gt.comment IS '备注'; + +CREATE TABLE IF NOT EXISTS fst_bag ( + id SERIAL PRIMARY KEY, + bag_id INT NOT NULL, + fst_node_id INT NOT NULL, + fst_node_level INT NOT NULL, + event_start_time INT, + event_end_time INT, + img_url VARCHAR(255), + video_url VARCHAR(255), + comments VARCHAR(255), + FOREIGN KEY (bag_id) REFERENCES bag_list(id) ON DELETE CASCADE, + FOREIGN KEY (fst_node_id) REFERENCES fst(id) ON DELETE CASCADE +); +COMMENT ON TABLE fst_bag IS 'FST 节点与 Bag 的关联表'; +COMMENT ON COLUMN fst_bag.id IS '主键'; +COMMENT ON COLUMN fst_bag.bag_id IS 'Bag ID'; +COMMENT ON COLUMN fst_bag.fst_node_id IS 'FST 节点 ID'; +COMMENT ON COLUMN fst_bag.fst_node_level IS '节点层级'; +COMMENT ON COLUMN fst_bag.event_start_time IS '事件开始时间(ms)'; +COMMENT ON COLUMN fst_bag.event_end_time IS '事件结束时间(ms)'; +COMMENT ON COLUMN fst_bag.img_url IS '事件截图 URL'; +COMMENT ON COLUMN fst_bag.video_url IS '事件视频 URL'; +COMMENT ON COLUMN fst_bag.comments IS '备注'; + +CREATE TABLE IF NOT EXISTS recompute_result ( + id SERIAL PRIMARY KEY, + bag_id INT NOT NULL, + recompute_version VARCHAR(100) NOT NULL, + result_type VARCHAR(50) NOT NULL, + storage_path VARCHAR(500) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'available', + created_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + reserved_json JSONB, + FOREIGN KEY (bag_id) REFERENCES bag_list(id) ON DELETE CASCADE, + UNIQUE (bag_id, recompute_version, result_type) +); +COMMENT ON TABLE recompute_result IS 'Recompute 结果存储表'; +COMMENT ON COLUMN recompute_result.id IS '主键'; +COMMENT ON COLUMN recompute_result.bag_id IS '关联 bag_list.id'; +COMMENT ON COLUMN recompute_result.recompute_version IS 'Recompute版本'; +COMMENT ON COLUMN recompute_result.result_type IS '结果类型 (如: perception, planning, etc)'; +COMMENT ON COLUMN recompute_result.storage_path IS '结果存储路径'; +COMMENT ON COLUMN recompute_result.status IS '结果状态: available, archived, deleted'; +COMMENT ON COLUMN recompute_result.created_time IS 'Recompute完成时间'; +COMMENT ON COLUMN recompute_result.updated_time IS '更新时间'; +COMMENT ON COLUMN recompute_result.reserved_json IS '预留JSON字段'; + +CREATE TABLE IF NOT EXISTS fst_stash ( + id BIGSERIAL PRIMARY KEY, + fst_versions VARCHAR(255) NOT NULL, + content JSONB, + created_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT fk_fst_stash_version FOREIGN KEY (fst_versions) REFERENCES version_list (version) ON DELETE CASCADE +); +COMMENT ON TABLE fst_stash IS 'FST 暂存表'; +COMMENT ON COLUMN fst_stash.id IS '主键'; +COMMENT ON COLUMN fst_stash.fst_versions IS '关联 version_list.version'; +COMMENT ON COLUMN fst_stash.content IS '暂存内容 (JSON)'; +COMMENT ON COLUMN fst_stash.created_time IS '创建时间'; +COMMENT ON COLUMN fst_stash.updated_time IS '更新时间'; + +-- ====================================================== +-- 审计日志表 +-- ====================================================== +CREATE TABLE IF NOT EXISTS ops_history ( + id BIGSERIAL PRIMARY KEY, + table_name REGCLASS NOT NULL, + row_pk TEXT NOT NULL, + column_name TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by NAME NOT NULL DEFAULT CURRENT_USER, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + comment TEXT, + app_module TEXT, + trace_id TEXT +); +CREATE INDEX IF NOT EXISTS idx_ops_history_changed_at ON ops_history (changed_at); +CREATE INDEX IF NOT EXISTS idx_ops_history_table_name ON ops_history (table_name); +COMMENT ON TABLE ops_history IS '通用审计日志:记录 DML 变更'; +COMMENT ON COLUMN ops_history.id IS '审计日志自增主键'; +COMMENT ON COLUMN ops_history.table_name IS '被审计表(系统元类型 REGCLASS)'; +COMMENT ON COLUMN ops_history.row_pk IS '行主键值;批量更新时形如 *col*rows*'; +COMMENT ON COLUMN ops_history.column_name IS '被修改列名;批量更新时为 *batch*'; +COMMENT ON COLUMN ops_history.old_value IS '旧值'; +COMMENT ON COLUMN ops_history.new_value IS '新值'; +COMMENT ON COLUMN ops_history.changed_by IS '数据库用户'; +COMMENT ON COLUMN ops_history.changed_at IS '变更时间'; +COMMENT ON COLUMN ops_history.comment IS '可读描述(自动生成或人工补充)'; +COMMENT ON COLUMN ops_history.app_module IS '应用模块(通过 SET audit.app_module = ''xxx'')'; +COMMENT ON COLUMN ops_history.trace_id IS '链路追踪 ID(通过 SET audit.trace_id = ''xxx'')'; + +-- ====================================================== +-- ⑤ 索引 +-- ====================================================== +CREATE INDEX IF NOT EXISTS idx_project_name ON project(name); +CREATE INDEX IF NOT EXISTS idx_bag_list_project_id ON bag_list(project_id); +CREATE INDEX IF NOT EXISTS idx_main_pangu_datetime ON main_pangu(datetime); +CREATE INDEX IF NOT EXISTS idx_main_pangu_name ON main_pangu(name); +CREATE INDEX IF NOT EXISTS idx_main_minerva_session_id ON main_minerva(session_id); +CREATE INDEX IF NOT EXISTS idx_main_minerva_start_ts ON main_minerva(start_ts); +CREATE INDEX IF NOT EXISTS idx_main_minerva_end_ts ON main_minerva(end_ts); +CREATE INDEX IF NOT EXISTS idx_main_minerva_vin ON main_minerva(vin); +CREATE INDEX IF NOT EXISTS idx_secondary_pangu_bag_id ON secondary_pangu(bag_id); +CREATE INDEX IF NOT EXISTS idx_secondary_minerva_bag_id ON secondary_minerva(bag_id); +CREATE INDEX IF NOT EXISTS idx_fst_name ON fst(name); +CREATE INDEX IF NOT EXISTS idx_topic_list_name ON topic_list(name); +CREATE INDEX IF NOT EXISTS idx_bag_topic_bag_id ON bag_topic(bag_id); +CREATE INDEX IF NOT EXISTS idx_bag_topic_topic_id ON bag_topic(topic_id); +CREATE INDEX IF NOT EXISTS idx_tag_list_name ON reserved_tag_list(name); +CREATE INDEX IF NOT EXISTS idx_bag_tag_bag_id ON bag_reserved_tag(bag_id); +CREATE INDEX IF NOT EXISTS idx_bag_tag_tag_id ON bag_reserved_tag(tag_id); +CREATE INDEX IF NOT EXISTS idx_gt_meta_name ON gt_meta(name); +CREATE INDEX IF NOT EXISTS idx_gt_meta_type ON gt_meta(type); +CREATE INDEX IF NOT EXISTS idx_bag_gt_gt_id ON bag_gt(gt_id); +CREATE INDEX IF NOT EXISTS idx_bag_gt_bag_id ON bag_gt(bag_id); + +CREATE INDEX IF NOT EXISTS idx_bag_list_project_active + ON bag_list(project_id) WHERE is_deleted = FALSE; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_taglist_name_not_deleted + ON reserved_tag_list (name) WHERE is_deleted = FALSE; + +CREATE INDEX IF NOT EXISTS idx_bag_list_json_gin + ON bag_list USING GIN (reserved_json); + +CREATE INDEX IF NOT EXISTS idx_recompute_result_version ON recompute_result(recompute_version); +CREATE INDEX IF NOT EXISTS idx_recompute_result_type ON recompute_result(result_type); +CREATE INDEX IF NOT EXISTS idx_recompute_result_status ON recompute_result(status); +CREATE INDEX IF NOT EXISTS idx_recompute_result_created_time ON recompute_result(created_time); +CREATE INDEX IF NOT EXISTS idx_recompute_result_storage_path ON recompute_result(storage_path); +CREATE INDEX IF NOT EXISTS idx_fst_stash_fst_versions ON fst_stash(fst_versions); + +CREATE INDEX IF NOT EXISTS idx_lifecycle_decode_time ON bag_lifecycle USING BRIN (decode_time); +CREATE INDEX IF NOT EXISTS idx_lifecycle_mining_time ON bag_lifecycle USING BRIN (mining_time); +CREATE INDEX IF NOT EXISTS idx_lifecycle_manual_annotate_time ON bag_lifecycle USING BRIN (manual_annotate_time); + +-- ====================================================== +-- ⑥ 触发器函数 +-- ====================================================== +CREATE OR REPLACE FUNCTION set_update_time() RETURNS TRIGGER AS +$$ +BEGIN + NEW.update_time := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +COMMENT ON FUNCTION set_update_time() IS '通用:更新行时自动刷新 update_time'; + +CREATE OR REPLACE FUNCTION audit_changes() RETURNS TRIGGER AS $$ +DECLARE + ch_col text; -- 变化列名(字符串) -- FIX + row_cnt int; -- 变化列总数(整数) -- FIX + oldv text; + newv text; + pkval text; + tmpl text; +BEGIN + /* 构造主键字符串 */ + SELECT string_agg( + coalesce(nullif(to_jsonb(NEW)->>attname, ''), 'null'), ',') + INTO pkval + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid + AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = TG_RELID AND i.indisprimary; + + IF TG_OP = 'UPDATE' THEN + /* 1. 变化列总数 */ + SELECT count(*)::int INTO row_cnt -- FIX:用独立变量 + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum > 0 AND NOT attisdropped + AND to_jsonb(OLD)->>attname IS DISTINCT FROM to_jsonb(NEW)->>attname; + + /* 2. 任意一个变化列的细节 */ + SELECT attname::text, + nullif(to_jsonb(OLD)->>attname, ''), + nullif(to_jsonb(NEW)->>attname, '') + INTO ch_col, oldv, newv -- FIX:列名放 ch_col + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum > 0 AND NOT attisdropped + AND to_jsonb(OLD)->>attname IS DISTINCT FROM to_jsonb(NEW)->>attname + LIMIT 1; + + IF FOUND THEN + tmpl := format('批量更新 %s.%s: %s → %s (共 %s 行)', + TG_TABLE_NAME, ch_col, oldv, newv, row_cnt); + + INSERT INTO ops_history(table_name, row_pk, column_name, + old_value, new_value, changed_by, comment, + app_module, trace_id) + VALUES (TG_RELID::REGCLASS, + '*'||row_cnt||'rows*', -- FIX:用整数变量 + '*batch*', + oldv, + jsonb_build_object('new_val', newv, 'rows', row_cnt), -- FIX + CURRENT_USER, + tmpl, + current_setting('audit.app_module', true), + current_setting('audit.trace_id', true)); + RETURN NEW; + END IF; + END IF; + + /* 以下逐列记录逻辑未改动,故省略 … */ + FOR ch_col IN + SELECT attname::text + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum > 0 AND NOT attisdropped + AND to_jsonb(OLD)->>attname IS DISTINCT FROM to_jsonb(NEW)->>attname + LOOP + oldv := nullif(to_jsonb(OLD)->>ch_col, ''); + newv := nullif(to_jsonb(NEW)->>ch_col, ''); + + tmpl := format('%s %s.%s: %s → %s', + TG_OP, TG_TABLE_NAME, ch_col, + coalesce(oldv,'(null)'), coalesce(newv,'(null)')); + + INSERT INTO ops_history(table_name, row_pk, column_name, + old_value, new_value, changed_by, comment, + app_module, trace_id) + VALUES (TG_RELID::REGCLASS, + pkval, + ch_col, + oldv, + newv, + CURRENT_USER, + tmpl, + current_setting('audit.app_module', true), + current_setting('audit.trace_id', true)); + END LOOP; + + RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END; +END; +$$ LANGUAGE plpgsql; + +-- ====================================================== +-- ⑦ 为每张业务表挂触发器 +-- ====================================================== +-- 7.1 自动刷新 update_time +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'project','bag_list','main_minerva','fst', + 'topic_list','reserved_tag_list','gt_meta', + 'bag_lifecycle','main_pangu','joined_pangu','joined_bags', + 'secondary_pangu','secondary_minerva','geometry_info', + 'bag_topic','bag_reserved_tag','bag_gt','fst_bag', + 'fst_stash' + ] LOOP + EXECUTE format($f$ + DROP TRIGGER IF EXISTS trg_%I_update ON %I; + CREATE TRIGGER trg_%I_update + BEFORE UPDATE ON %I + FOR EACH ROW + EXECUTE FUNCTION set_update_time(); + $f$, t, t, t, t); + END LOOP; +END $$; + +-- 7.2 审计触发器(所有表) +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOREACH tbl IN ARRAY ARRAY[ + 'project','bag_list','main_minerva','fst', + 'topic_list','reserved_tag_list','gt_meta', + 'bag_lifecycle','main_pangu','joined_pangu','joined_bags', + 'secondary_pangu','secondary_minerva','geometry_info', + 'bag_topic','bag_reserved_tag','bag_gt','fst_bag', + 'fst_stash' + ] LOOP + EXECUTE format($f$ + DROP TRIGGER IF EXISTS trg_audit_%I ON %I; + CREATE TRIGGER trg_audit_%I + AFTER INSERT OR UPDATE OR DELETE ON %I + FOR EACH ROW + EXECUTE FUNCTION audit_changes(); + $f$, tbl, tbl, tbl, tbl); + END LOOP; +END $$; diff --git a/fst_data_pipeline/apps/root_db_api/dev/postman/ROOT API.postman_collection.json b/fst_data_pipeline/apps/root_db_api/dev/postman/ROOT API.postman_collection.json new file mode 100644 index 0000000..526e429 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/dev/postman/ROOT API.postman_collection.json @@ -0,0 +1,679 @@ +{ + "info": { + "_postman_id": "a24cba51-2034-4777-a911-85ce7240d1ac", + "name": "ROOT API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "17291944" + }, + "item": [ + { + "name": "projects", + "item": [ + { + "name": "All projects", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/projects/all", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "projects", + "all" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "tags", + "item": [ + { + "name": "All tags", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/tags/all", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "tags", + "all" + ] + } + }, + "response": [] + }, + { + "name": "get_by_creator", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/tags/creators/admin", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "tags", + "creators", + "admin" + ] + } + }, + "response": [] + }, + { + "name": "tag2bag", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/tags/bags?tags=driving,city&op=and", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "tags", + "bags" + ], + "query": [ + { + "key": "tags", + "value": "driving,city" + }, + { + "key": "op", + "value": "and" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "bags", + "item": [ + { + "name": "All bags", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/bags/all", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "all" + ] + } + }, + "response": [] + }, + { + "name": "bag2topic", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"001.bag\",\"002.bag\"]\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/topics", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "topics" + ] + } + }, + "response": [] + }, + { + "name": "bag2fst node", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"001.bag\",\"002.bag\"]\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/fst/nodes", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "fst", + "nodes" + ] + } + }, + "response": [] + }, + { + "name": "bag2tags", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"001.bag\",\"002.bag\",\"test21.bag\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/tags", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "tags" + ] + } + }, + "response": [] + }, + { + "name": "bags_life_cycle", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"bag_names\":[\"001.bag\",\"002.bag\"]}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/life_cycle", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "life_cycle" + ] + } + }, + "response": [] + }, + { + "name": "get_pangu", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"names\":[\"p1.bag\",\"p2.bag\"]}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/pangu", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "pangu" + ] + } + }, + "response": [] + }, + { + "name": "get_minerva", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"session_ids\":[\"p1.bag\",\"p2.bag\"]}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/minerva", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "minerva" + ] + } + }, + "response": [] + }, + { + "name": "search pangu", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"name\":\"001.bag\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/search/pangu", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "search", + "pangu" + ] + } + }, + "response": [] + }, + { + "name": "get_pangu_detail", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"p1.bag\",\"p2.bag\",\"test21.bag\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/pangu/detail", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "pangu", + "detail" + ] + } + }, + "response": [] + }, + { + "name": "get_minerva_detail", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"p1.bag\",\"p2.bag\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/bags/minerva/detail", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "bags", + "minerva", + "detail" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "topics", + "item": [ + { + "name": "All topics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/topics/all", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "topics", + "all" + ] + } + }, + "response": [] + }, + { + "name": "topic2bag", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/topics/bags/topic1", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "topics", + "bags", + "topic1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "geometry", + "item": [ + { + "name": "rosbags", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"rosbag_names\":[\"001.bag\"]}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/geometry", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "geometry" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "fst", + "item": [ + { + "name": "get_by_bags_fst_node", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"fst1\",\"fst1_fst2_fst3\",\"fst1_fst2\",\"fst1_fst4\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/fst/bags/nodes", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "bags", + "nodes" + ] + } + }, + "response": [] + }, + { + "name": "add_bag_to_fst", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\n {\n \"bag_name\": \"test21.bag\",\n \"nodes\": [\"fst1\", \"fst1_fst2\", \"fst1_fst2_fst3\", \"\"],\n \"start_time\": 5,\n \"end_time\": 15,\n \"comments\":\"test\",\n \"tags\":[\"driving\",\"city\",\"highway\"]\n }\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/fst/bags/update", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "bags", + "update" + ] + } + }, + "response": [] + }, + { + "name": "update_fst_node", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"fst-qqq-123\",\n \"parent_name\":\"fst1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/fst/update", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "update" + ] + } + }, + "response": [] + }, + { + "name": "delete_fst_nold", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "[\n {\n \"bag_name\": \"test21.bag\",\n \"nodes\": [\"fst1\", \"fst1_fst2\", \"fst1_fst2_fst3\", \"\"],\n \"start_time\": 5,\n \"end_time\": 15,\n \"comments\":\"test\",\n \"tags\":[\"driving\",\"city\",\"highway\"]\n }\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/fst/fst-qqq-123", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "fst-qqq-123" + ] + } + }, + "response": [] + }, + { + "name": "get_by_bags_fst_path", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"fst1\",\"fst2\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/fst/bags/path/fst1", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "bags", + "path", + "fst1" + ] + } + }, + "response": [] + }, + { + "name": "get_fst_detail_by_name", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/fst/fst1_fst2_fst3", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "fst1_fst2_fst3" + ] + } + }, + "response": [] + }, + { + "name": "print_tree", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/fst/print_tree", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "print_tree" + ] + } + }, + "response": [] + }, + { + "name": "get_all_linked_bags", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/fst/baglist", + "host": [ + "{{baseURL}}" + ], + "path": [ + "api", + "fst", + "baglist" + ] + } + }, + "response": [] + } + ] + } + ], + "variable": [ + { + "key": "baseURL", + "value": "http://127.0.0.1:5232", + "type": "default" + } + ] +} \ No newline at end of file diff --git a/fst_data_pipeline/apps/root_db_api/pyproject.toml b/fst_data_pipeline/apps/root_db_api/pyproject.toml new file mode 100644 index 0000000..a9f5d09 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/pyproject.toml @@ -0,0 +1,55 @@ +[project] +name = "root_db_api" +version = "0.5.0" +description = "A core API service for database operations." +readme = "README.md" +authors = [{ name = "Cheng Li", email = "tbd@tbd.com" }] +requires-python = ">=3.12" + +# ---------------------------------------------------- +# 核心依赖项 (Direct Dependencies Only) +# ---------------------------------------------------- +dependencies = [ + # Web 框架 / 服务 + "flask>=3.1.1", + "flasgger==0.9.7b2", + "geoalchemy2==0.17.1", + "shapely>=2.1.1", + # 实用工具 / 缓存 / 监控 + "numpy==2.3.1", + "folium==0.20.0", + "tenacity==9.1.2", + "tqdm==4.67.1", + "pytz==2025.2", + "prometheus-client==0.22.1", + "flask-caching>=2.3.1", + "redis>=6.4.0", + # 配置 / 外部服务 + "cos-python-sdk-v5==1.9.37", + "python-dotenv==1.1.1", + "requests==2.32.4", + "pyyaml==6.0.2", + "pydantic==2.11.7", + "sqlalchemy==2.0.41", + "psycopg2-binary==2.9.10", + "openpyxl>=3.1.0", + "gunicorn>=23.0.0", + "pytest==8.4.1", + "pytest-cov==6.2.0", + "pytest-mock==3.14.0", + "flask-sqlalchemy>=3.1.1", +] +# ---------------------------------------------------- +# 标准构建系统配置(使用 Setuptools) +# ---------------------------------------------------- +[build-system] +requires = ["setuptools>=61.0.0"] +build-backend = "setuptools.build_meta" + + +# ---------------------------------------------------- +# uv 工具配置(用于 Workspaces 或 PyPI 镜像) +# ---------------------------------------------------- +[[tool.uv.index]] +url = "https://pypi.mirrors.ustc.edu.cn/simple" +default = true diff --git a/fst_data_pipeline/apps/root_db_api/src/__init__.py b/fst_data_pipeline/apps/root_db_api/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/root_db_api/src/api/__init__.py b/fst_data_pipeline/apps/root_db_api/src/api/__init__.py new file mode 100644 index 0000000..dd2e15e --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/__init__.py @@ -0,0 +1,23 @@ +from flask import Blueprint + +from .bags import bp as bags_bp +from .fst import bp as fst_bp +from .geometry import bp as geometry_bp +from .gt import bp as gt_bp +from .projects import bp as projects_bp +from .recompute import bp as recompute_bp +from .tags import bp as tags_bp +from .topics import bp as topics_bp +from .versions import bp as versions_bp + +api_bp = Blueprint("api", __name__) + +api_bp.register_blueprint(bags_bp, url_prefix="/bags") +api_bp.register_blueprint(fst_bp, url_prefix="/fst") +api_bp.register_blueprint(projects_bp, url_prefix="/projects") +api_bp.register_blueprint(tags_bp, url_prefix="/tags") +api_bp.register_blueprint(topics_bp, url_prefix="/topics") +api_bp.register_blueprint(geometry_bp, url_prefix="/geometry") +api_bp.register_blueprint(gt_bp, url_prefix="/gt") +api_bp.register_blueprint(recompute_bp, url_prefix="/recompute") +api_bp.register_blueprint(versions_bp, url_prefix="/versions") diff --git a/fst_data_pipeline/apps/root_db_api/src/api/bags.py b/fst_data_pipeline/apps/root_db_api/src/api/bags.py new file mode 100644 index 0000000..b54f89a --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/bags.py @@ -0,0 +1,1723 @@ +from collections import defaultdict +from datetime import datetime, date +from typing import List, Optional + +from flasgger import swag_from +from flask import Blueprint, request, jsonify +from pydantic import BaseModel +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from fst_data_pipeline.apps.root_db_api.src.core import service +from fst_data_pipeline.apps.root_db_api.src.core.service import ( + get_bags, + get_fst_nodes_by_bag_name, + get_tags_by_bag_name, + get_main_pangu_by_name, + get_pangu_details_by_bag_name, + get_topics_by_bag_name, + get_main_pangu_by_conditions, + get_bag_lifecycle_by_bag_names, + get_main_minerva_by_session_id, + get_minerva_details_by_bag_name, + get_all_related_bags, + merge_closed_bags, + query_existed_joined_bag, + delete_existed_joined_bag, + query_all_joined_bags, + search_aggregate_bag_ids, + get_aggregate_bag_details, +) +from fst_data_pipeline.apps.root_db_api.src.db.connection import ( + SessionLocal, +) + + +class NamesIn(BaseModel): + names: List[str] + + +bp = Blueprint("bags", __name__, url_prefix="/bags") + + +# Get all Bags +@bp.route("/all", methods=["GET"]) +@swag_from({ + "responses": { + 200: { + "description": "返回按项目分组的所有bag列表", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"}, + }, + "example": { + "项目A": ["Bag 1", "Bag 2"], + "未分类": ["Bag 3"], + }, + }, + }, + 500: { + "description": "服务器内部错误", + "schema": { + "type": "object", + "properties": {"error": {"type": "string", "example": "发生错误"}}, + }, + }, + }, + "tags": ["Bag管理"], + "summary": "获取所有bag文件", + "description": "获取所有bag文件并按其关联的项目进行分组", +}) +def read_bags(): + db: Session = SessionLocal() + try: + bags = get_bags(db) + grouped = defaultdict(list) + for bag in bags: + key = bag.project.name if bag.project else "Uncategorized" + grouped[key].append(bag.name) + + return jsonify(grouped) + finally: + db.close() + + +# Search Main Pangu by names +@bp.route("/pangu", methods=["POST"]) +@swag_from({ + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": {"type": "string"}, + "example": ["name1", "name2"], + "description": "bag名称列表", + } + }, + "required": ["names"], + } + } + }, + }, + "responses": { + 200: { + "description": "返回指定名称的Pangu数据详情列表", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "名称"}, + "vehicle": { + "type": "string", + "description": "车辆编号", + }, + "datetime": { + "type": "string", + "format": "date-time", + "description": "数据时间", + }, + "raw_bag_path": { + "type": "string", + "description": "原始bag路径", + }, + "decoded_path": { + "type": "string", + "description": "解码后数据路径", + }, + "reserved_str": { + "type": "string", + "description": "预留字符串", + }, + "reserved_json": { + "type": "object", + "description": "预留JSON数据", + }, + }, + "example": { + "name": "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "vehicle": "PL061763", + "datetime": "2023-08-07T11:56:27Z", + "raw_bag_path": "/pangu/bags/原始bag路径", + "decoded_path": "/pangu/data/解码路径", + "reserved_str": "预留字符串", + "reserved_json": {}, + }, + }, + }, + }, + }, + 400: { + "description": "无效输入", + "schema": { + "type": "object", + "properties": {"error": {"type": "string", "example": "无效输入"}}, + }, + }, + 500: { + "description": "服务器内部错误", + "schema": { + "type": "object", + "properties": {"error": {"type": "string", "example": "发生错误"}}, + }, + }, + }, + "tags": ["Bag管理"], + "summary": "根据名称批量查询Pangu数据", + "description": "根据提供的多个bag名称批量查询对应的Pangu数据详情", +}) +def search_main_pangu_by_names(): + """Batch search Main Pangu by multiple names""" + db: Session = SessionLocal() + try: + payload = NamesIn(**request.get_json(force=True)) + result = {} + for name in payload.names: + rows = get_main_pangu_by_name(db, name=name) + result[name] = [ + { + "name": p.name, + "vehicle": p.vehicle, + "datetime": p.datetime, + "raw_bag_path": p.bag_path, + "decoded_path": p.data_path, + "reserved_str": p.reserved_str, + "reserved_json": p.reserved_json, + } + for p in rows + ] + return jsonify(result) + finally: + db.close() + + +# Get Pangu details by bag names +@bp.route("/pangu/detail", methods=["POST"]) +@swag_from({ + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"type": "string"}, + "example": ["bag1.bag", "bag2.bag"], + "description": "bag名称列表", + } + } + }, + }, + "responses": { + 200: { + "description": "返回每个bag名称对应的Pangu详细信息", + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "Pangu详细数据,包含传感器路径等信息", + }, + "example": { + "bag1.bag": { + "detail": "详细信息...", + "other_property": "其他属性...", + }, + "bag2.bag": { + "detail": "详细信息...", + "other_property": "其他属性...", + }, + }, + }, + }, + 400: { + "description": "无效输入", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "请求体必须是bag名称列表", + } + }, + }, + }, + 500: { + "description": "服务器内部错误", + "schema": { + "type": "object", + "properties": { + "error": {"type": "string", "example": "发生错误"}, + }, + }, + }, + }, + "tags": ["Bag管理"], + "summary": "根据bag名称获取Pangu详细信息", + "description": "根据bag名称列表批量查询对应的Pangu详细信息,包含传感器数据路径等", +}) +def get_pangu_details_batch(): + """POST to batch query Pangu details""" + names: List[str] = request.get_json(force=True) + if not isinstance(names, list): + return jsonify({"error": "Body must be a list of bag names"}), 400 + + db: Session = SessionLocal() + try: + result = {} + for bag_name in names: + result[bag_name] = get_pangu_details_by_bag_name(db, bag_name=bag_name) + return jsonify(result) + finally: + db.close() + + +# 根据 session_id 搜索 Main Minerva +@bp.route("/minerva", methods=["POST"]) +@swag_from({ + "tags": ["Bag管理"], + "summary": "根据会话ID批量查询Minerva数据", + "description": "根据会话ID列表批量查询Minerva数据记录", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "session_ids": { + "type": "array", + "items": {"type": "string"}, + "example": ["p1.bag", "p2.bag"], + } + }, + "required": ["session_ids"], + } + } + }, + }, + "responses": { + 200: { + "description": "Success", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "vin": {"type": "string"}, + "start_ts": {"type": "number"}, + "end_ts": {"type": "number"}, + "platform": {"type": "string"}, + "path": {"type": "string"}, + "converted_path": {"type": "string"}, + "gt_path": {"type": "string"}, + "datetime": {"type": "string"}, + "length": {"type": "number"}, + "reserved_json": {"type": "object"}, + }, + }, + }, + }, + }, + 400: {"description": "Invalid request body"}, + }, +}) +def search_main_minerva_by_session_ids(): + """ + Batch query Main Minerva records by a list of session_ids. + Request body example: + {"session_ids": ["p1.bag", "p2.bag"]} + """ + db: Session = SessionLocal() + try: + body = request.get_json(force=True) + session_ids = body.get("session_ids", []) + if not isinstance(session_ids, list): + return jsonify({"error": "session_ids must be a list"}), 400 + + result = {} + for sid in session_ids: + rows = get_main_minerva_by_session_id(db, session_id=sid) + result[sid] = [ + { + "session_id": p.session_id, + "vin": p.vin, + "start_ts": p.start_ts, + "end_ts": p.end_ts, + "platform": p.platform, + "path": p.path, + "converted_path": p.converted_path, + "gt_path": p.gt_path, + "datetime": p.datetime, + "length": p.length, + "reserved_json": p.reserved_json, + } + for p in rows + ] + return jsonify(result) + finally: + db.close() + + +# 根据 Bag 名称获取 Minerva 详情 +@bp.route("/minerva/detail", methods=["POST"]) +@swag_from({ + "tags": ["Bag管理"], + "summary": "根据bag名称批量查询Minerva详细信息", + "description": "根据bag名称列表批量查询Minerva详细信息", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"type": "string"}, + "example": ["p1.bag", "p2.bag"], + } + } + }, + }, + "responses": { + 200: { + "description": "Success", + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "vin": {"type": "string"}, + "start_ts": {"type": "number"}, + "end_ts": {"type": "number"}, + "platform": {"type": "string"}, + "path": {"type": "string"}, + "converted_path": {"type": "string"}, + "gt_path": {"type": "string"}, + "datetime": {"type": "string"}, + "length": {"type": "number"}, + "reserved_json": {"type": "object"}, + }, + }, + }, + }, + 400: {"description": "Body must be a list of bag names"}, + }, +}) +def get_minerva_details_batch(): + """ + Batch query Minerva details by bag names. + Request body example: + ["p1.bag", "p2.bag"] + """ + names: List[str] = request.get_json(force=True) + if not isinstance(names, list): + return jsonify({"error": "Body must be a list of bag names"}), 400 + + db: Session = SessionLocal() + try: + result = {} + for bag_name in names: + result[bag_name] = get_minerva_details_by_bag_name(db, session_id=bag_name) + return jsonify(result) + finally: + db.close() + + +# 根据 Bag 名称获取topics +@bp.route("/topics", methods=["POST"]) +@swag_from({ + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"type": "string"}, + "example": ["a.bag", "b.bag"], + } + } + }, + }, + "responses": { + 200: { + "description": "Returns topics for each provided bag name.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "type": {"type": "string"}, + }, + "example": {"name": "topic1", "type": "type1"}, + }, + }, + "example": { + "a.bag": [ + {"name": "topic1", "type": "type1"}, + {"name": "topic2", "type": "type2"}, + ], + "b.bag": [{"name": "topic3", "type": "type3"}], + }, + }, + }, + 400: { + "description": "Invalid input", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Body must be a list of bag names", + } + }, + }, + }, + 500: { + "description": "Internal server error", + "schema": { + "type": "object", + "properties": { + "error": {"type": "string", "example": "An error occurred"} + }, + }, + }, + }, + "tags": ["Bag管理"], + "summary": "Get topics for multiple bags", + "description": "Given a list of bag names, fetch the topics associated with each bag.", +}) +def get_topics_batch(): + """POST to batch: {"bag_names": ["a.bag", "b.bag"]}""" + bag_names: List[str] = request.get_json(force=True, silent=True) or [] + if not isinstance(bag_names, list): + return jsonify({"error": "Body must be a list of bag names"}), 400 + + db: Session = SessionLocal() + try: + result = {} + for name in bag_names: + topics = get_topics_by_bag_name(db, bag_name=name) + result[name] = [{"name": t.name, "type": t.type} for t in topics] + return jsonify(result) + finally: + db.close() + + +# Get tags for multiple bags +@bp.route("/tags", methods=["POST"]) +@swag_from({ + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"type": "string"}, + "example": ["a.bag", "b.bag"], + } + } + }, + }, + "responses": { + 200: { + "description": "Returns reserved tags for each provided bag name.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "type": {"type": "string"}, + }, + "example": {"name": "tag1", "type": "type1"}, + }, + }, + "example": { + "a.bag": [ + {"name": "tag1", "type": "type1"}, + {"name": "tag2", "type": "type2"}, + ], + "b.bag": [{"name": "tag3", "type": "type3"}], + }, + }, + }, + 400: { + "description": "Invalid input", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Body must be a list of bag names", + } + }, + }, + }, + 500: { + "description": "Internal server error", + "schema": { + "type": "object", + "properties": { + "error": {"type": "string", "example": "An error occurred"} + }, + }, + }, + }, + "tags": ["Bag管理"], + "summary": "Get reserved tags for multiple bags", + "description": "Given a list of bag names, fetch the tags associated with each bag.", +}) +def get_tags_batch(): + """POST to batch: {"bag_names": ["a.bag", "b.bag"]}""" + bag_names: List[str] = request.get_json(force=True, silent=True) or [] + if not isinstance(bag_names, list): + return jsonify({"error": "Body must be a list of bag names"}), 400 + + db: Session = SessionLocal() + try: + result = {} + for name in bag_names: + tags = get_tags_by_bag_name(db, bag_name=name) + result[name] = [{"name": t.name, "type": t.type} for t in tags] + return jsonify(result) + finally: + db.close() + + +# Get FST nodes for multiple bags +@bp.route("/fst/nodes", methods=["POST"]) +@swag_from({ + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"type": "string"}, + "example": ["a.bag", "b.bag"], + } + } + }, + }, + "responses": { + 200: { + "description": "Returns FST nodes for each provided bag name.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "start": {"type": "integer"}, + "end": {"type": "integer"}, + }, + "example": {"name": "node1", "start": 0, "end": 10}, + }, + }, + "example": { + "a.bag": [ + {"name": "node1", "start": 0, "end": 10}, + {"name": "node2", "start": 11, "end": 20}, + ], + "b.bag": [{"name": "node3", "start": 21, "end": 30}], + }, + }, + }, + 400: { + "description": "Invalid input", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Body must be a list of bag names", + } + }, + }, + }, + 500: { + "description": "Internal server error", + "schema": { + "type": "object", + "properties": { + "error": {"type": "string", "example": "An error occurred"} + }, + }, + }, + }, + "tags": ["Bag管理"], + "summary": "Get FST nodes for multiple bags", + "description": "Given a list of bag names, fetch the FST nodes associated with each bag.", +}) +def get_fst_nodes_batch(): + """POST to batch: {"bag_names": ["a.bag", "b.bag"]}""" + bag_names = request.get_json(force=True, silent=True) or [] + if not isinstance(bag_names, list): + return jsonify({"error": "Body must be a list of bag names"}), 400 + + db: Session = SessionLocal() + try: + result = {} + for name in bag_names: + nodes = get_fst_nodes_by_bag_name(db, bag_name=name) + result[name] = nodes # Already in the format [{name,start,end}, ...] + return jsonify(result) + finally: + db.close() + + +# Search Main Pangu +def _parse_date_from_value(key: str) -> Optional[date]: + if not key: + return None + try: + return datetime.strptime(key, "%Y%m%d").date() + except ValueError: + raise ValueError(f"{key} must be YYYYMMDD") + + +@bp.route("/search/pangu", methods=["POST"]) +@swag_from({ + "tags": ["Bag管理"], + "summary": "Search Main Pangu", + "description": "Query by date range(YYYYMMDD), vehicle, keyword, topics, fst", + "requestBody": { + "required": False, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "starttime": { + "type": "string", + "format": "date", + "description": "Start date (YYYYMMDD)", + }, + "endtime": { + "type": "string", + "format": "date", + "description": "End date (YYYYMMDD)", + }, + "vehicle": { + "type": "string", + "description": "Vehicle name (suffix match, case-insensitive)", + }, + "keyword": { + "type": "string", + "description": "Keyword in pangu name (fuzzy, case-insensitive)", + }, + "topics": { + "type": "array", + "items": {"type": "string"}, + "description": "Topic name prefixes (case-insensitive, OR match)", + }, + "fst": { + "type": "string", + "description": "FST node name (exact match) and all its descendants", + }, + }, + } + } + }, + }, + "responses": { + "200": { + "description": "List of Main Pangu", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": { + "type": "string", + "format": "date-time", + }, + }, + }, + }, + }, + "400": {"description": "Bad request"}, + "500": {"description": "Internal server error"}, + }, +}) +def search_pangu(): + data = request.get_json(silent=True) or {} + print(data) + + try: + start = _parse_date_from_value(data.get("starttime")) + end = _parse_date_from_value(data.get("endtime")) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + vehicle = data.get("vehicle") + keyword = data.get("keyword") + topics = data.get("topics") or [] + fst = data.get("fst") + + # 参数防御 + if topics and not isinstance(topics, list): + return jsonify({"error": "topics must be an array"}), 400 + + db = SessionLocal() + try: + rows = get_main_pangu_by_conditions( + db, + start_time=start, + end_time=end, + vehicle=vehicle, + keyword=keyword, + topics=topics, + fst=fst, + limit=1000, + ) + return jsonify([r.name for r in rows]), 200 + except SQLAlchemyError: + return jsonify({"error": "Database error"}), 500 + finally: + db.close() + + +@bp.route("/search/aggregate", methods=["POST"]) +@swag_from({ + "tags": ["Bag管理"], + "summary": "Aggregate search for bags", + "description": "按 MainPangu.datetime(YYYYMMDD)、GT名称、FST一级、车号、软硬件版本、topics、tags 进行筛选,支持分页;gt_names/topics/tags 为 OR 语义。", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": {"type": "integer", "default": 1}, + "description": "页码,默认 1", + }, + { + "name": "per_page", + "in": "query", + "schema": {"type": "integer", "default": 20, "maximum": 100}, + "description": "每页数量,默认 20,最大 100", + }, + { + "name": "debug_sql", + "in": "query", + "schema": {"type": "boolean", "default": False}, + "description": "是否打印 SQL(仅日志输出)", + }, + ], + "requestBody": { + "required": False, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "collect_start": { + "type": "string", + "description": "起始日期,基于 MainPangu.datetime (YYYYMMDD)", + "example": "20250101", + }, + "collect_end": { + "type": "string", + "description": "结束日期,基于 MainPangu.datetime (YYYYMMDD)", + "example": "20250131", + }, + "bag_name": { + "type": "string", + "description": "bag 名称模糊匹配(支持 * 通配)", + "example": "xxx*2025*", + }, + "gt_names": { + "type": "array", + "items": {"type": "string"}, + "description": "GT 名称列表 (OR 语义)", + "example": ["gt_a", "gt_b"], + }, + "fst_level1": { + "type": "string", + "description": "FST 一级节点名称", + "example": "root_fst", + }, + "vehicles": { + "type": "array", + "items": {"type": "string"}, + "description": "车号列表(OR 语义,精确匹配)", + "example": ["PL061763", "PL061764"], + }, + "sw_version": { + "type": "string", + "description": "软件版本(支持 * 通配)", + "example": "v1.*", + }, + "hw_version": { + "type": "string", + "description": "硬件版本(支持 * 通配)", + "example": "h1.*", + }, + "topics": { + "type": "array", + "items": {"type": "string"}, + "description": "Topic 名称列表 (OR 语义)", + "example": ["topic_a", "topic_b"], + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tag 名称列表 (OR 语义)", + "example": ["tag_a", "tag_b"], + }, + }, + "example": { + "collect_start": "20250101", + "collect_end": "20250131", + "bag_name": "xxx*2025*", + "gt_names": ["gt_a"], + "fst_level1": "root_fst", + "vehicles": ["PL061763"], + "sw_version": "v1.*", + "hw_version": "h1.*", + "topics": ["topic_a"], + "tags": ["tag_a"], + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Aggregate bag list", + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_id": {"type": "integer"}, + "bag_name": {"type": "string"}, + "bag_path": {"type": "string"}, + "data_path": {"type": "string"}, + "datetime": {"type": "string"}, + "collect_time": {"type": "string"}, + "vehicle": {"type": "string"}, + "sw_version": {"type": "string"}, + "hw_version": {"type": "string"}, + "mviz_link": {"type": "string"}, + "mbviz_link": {"type": "string"}, + "fst": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + "level2": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + "level3": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + "level4": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + }, + }, + }, + "gt": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "path": {"type": "string"}, + "comment": {"type": "string"}, + }, + }, + }, + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + }, + }, + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "creator": {"type": "string"}, + }, + }, + }, + }, + }, + }, + "page": {"type": "integer"}, + "per_page": {"type": "integer"}, + "total": {"type": "integer"}, + }, + "example": { + "items": [ + { + "bag_id": 123, + "bag_name": "xxx.bag", + "bag_path": "/path/to/bag", + "data_path": "/path/to/data", + "datetime": "2025-07-16 00:00:00", + "collect_time": "2025-07-16 00:00:00", + "vehicle": "PL061763", + "sw_version": "v1.2.3", + "hw_version": "h1.0", + "mviz_link": "", + "mbviz_link": "", + "fst": [ + { + "level1": {"id": 1, "name": "L1"}, + "level2": {"id": 2, "name": "L2"}, + "level3": {"id": 3, "name": "L3"}, + "level4": {"id": 4, "name": "L4"}, + } + ], + "gt": [ + { + "id": 10, + "name": "gt_a", + "type": "3DBox", + "path": "/path/to/gt", + "comment": "备注", + } + ], + "topics": [ + { + "id": 20, + "name": "topic_a", + "type": "sensor_msgs/PointCloud2", + } + ], + "tags": [ + { + "id": 30, + "name": "tag_a", + "type": "user", + "creator": "alice", + } + ], + } + ], + "page": 1, + "per_page": 20, + "total": 123, + }, + }, + }, + "400": {"description": "Bad request"}, + "500": {"description": "Internal server error"}, + }, +}) +def search_aggregate(): + data = request.get_json(silent=True) or {} + + try: + collect_start = _parse_date_from_value(data.get("collect_start")) + collect_end = _parse_date_from_value(data.get("collect_end")) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + if collect_start and collect_end and collect_start > collect_end: + return jsonify({"error": "collect_start must be <= collect_end"}), 400 + + try: + page = max(int(request.args.get("page", 1)), 1) + per_page = min(max(int(request.args.get("per_page", 20)), 1), 100) + except ValueError: + return jsonify({"error": "page & per_page must be positive integers"}), 400 + debug_sql = str(request.args.get("debug_sql", "")).lower() in ("1", "true", "yes") + + topics = data.get("topics") or [] + tags = data.get("tags") or [] + gt_names = data.get("gt_names") or [] + vehicles = data.get("vehicles") or [] + + if topics and not isinstance(topics, list): + return jsonify({"error": "topics must be an array"}), 400 + if tags and not isinstance(tags, list): + return jsonify({"error": "tags must be an array"}), 400 + if gt_names and not isinstance(gt_names, list): + return jsonify({"error": "gt_names must be an array"}), 400 + if vehicles and not isinstance(vehicles, list): + return jsonify({"error": "vehicles must be an array"}), 400 + + fst_level1 = data.get("fst_level1") + bag_name = data.get("bag_name") + sw_version = data.get("sw_version") + hw_version = data.get("hw_version") + + db = SessionLocal() + try: + bag_ids, total = search_aggregate_bag_ids( + db, + collect_start=collect_start, + collect_end=collect_end, + bag_name=bag_name, + gt_names=gt_names, + fst_name=fst_level1, + vehicles=vehicles, + sw_version=sw_version, + hw_version=hw_version, + topics=topics, + tags=tags, + page=page, + per_page=per_page, + debug_sql=debug_sql, + ) + items = get_aggregate_bag_details(db, bag_ids) + return jsonify({ + "items": items, + "page": page, + "per_page": per_page, + "total": total, + }), 200 + except SQLAlchemyError: + return jsonify({"error": "Database error"}), 500 + finally: + db.close() + + +# Get bag lifecycle by bag names +@bp.route("/life_cycle", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", # 新增:声明 OpenAPI 3 + "tags": ["Bag管理"], + "summary": "Get lifecycle information by bag names", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": {"type": "array", "items": {"type": "string"}} + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Successfully retrieved lifecycle information", + "content": { # 新增:content 包装 + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "collect_time": { + "type": "string", + "format": "date-time", + }, + "clone2dev_time": { + "type": "string", + "format": "date-time", + }, + "decode_time": { + "type": "string", + "format": "date-time", + }, + "mining_time": { + "type": "string", + "format": "date-time", + }, + "auto_annotate_time": { + "type": "string", + "format": "date-time", + }, + "manual_annotate_time": { + "type": "string", + "format": "date-time", + }, + "fst_index_time": { + "type": "string", + "format": "date-time", + }, + }, + }, + } + } + }, + }, + "500": { + "description": "An error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def get_bag_lifecycle(): + """Get lifecycle information by bag names""" + db: Session = SessionLocal() + try: + # Get bag_names from request body + data = request.get_json() + bag_names = data.get("bag_names", []) + + # Query lifecycle information + bag_lifecycles = get_bag_lifecycle_by_bag_names(db, bag_names) + + # Format results + result = { + bag_lifecycle.bag_name: { + "collect_time": bag_lifecycle.collect_time, + "clone2dev_time": bag_lifecycle.clone2dev_time, + "decode_time": bag_lifecycle.decode_time, + "mining_time": bag_lifecycle.mining_time, + "auto_annotate_time": bag_lifecycle.auto_annotate_time, + "manual_annotate_time": bag_lifecycle.manual_annotate_time, + "fst_index_time": bag_lifecycle.fst_index_time, + } + for bag_lifecycle in bag_lifecycles + } + + return jsonify(result), 200 + + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +@bp.route("/joined", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["Joined-bag"], + "summary": "Get neighbour bags before/after the specified bag by datetime", + "parameters": [ + { + "name": "bag_name", + "in": "query", + "required": True, + "schema": {"type": "string"}, + "description": "Name of the central bag", + }, + { + "name": "before", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 0, "default": 5}, + "description": "How many bags to return before the central bag", + }, + { + "name": "after", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 0, "default": 5}, + "description": "How many bags to return after the central bag", + }, + ], + "responses": { + "200": { + "description": "List of neighbour bags including the central one", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": { + "type": "string", + "format": "date-time", + }, + "bag_path": {"type": "string"}, + "data_path": {"type": "string"}, + }, + }, + } + } + }, + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def get_related_bags(): + """Get related bags""" + db: Session = SessionLocal() + before = int(request.args.get("before")) + after = int(request.args.get("after")) + bag_name = request.args.get("bag_name") + try: + data = get_all_related_bags(db, bag_name, before, after) + return jsonify(data), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +@bp.route("/joined/create", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["Joined-bag"], + "summary": "Create a merged parent bag from sub-bags", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "example": ["A", "B", "C"], + } + }, + "required": ["bag_names"], + } + } + }, + }, + "responses": { + "201": { + "description": "Merge successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "joined_id": {"type": "integer"}, + "joined_name": {"type": "string"}, + }, + } + } + }, + }, + "400": { + "description": "Invalid input or business rule violation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + }, + }, + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def merge_bags(): + """Merge related bags""" + db: Session = SessionLocal() + try: + body = request.get_json(force=True) or {} + bag_names = body.get("bag_names") + if not bag_names or not isinstance(bag_names, list) or len(bag_names) == 0: + return jsonify({"error": "bag_names must be a non-empty list"}), 400 + data = merge_closed_bags(db, bag_names) + return jsonify(data), 201 + except ValueError as ve: + return jsonify({"error": str(ve)}), 400 + except Exception as e: + return jsonify({"error": "Internal server error"}), 500 + finally: + db.close() + + +@bp.route("/joined/query", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["Joined-bag"], + "summary": "Query existing merged relationships by bag names (parent or child)", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "example": ["A_C_merged", "B"], + } + }, + "required": ["bag_names"], + } + } + }, + }, + "responses": { + "200": { + "description": "Map of parent_name -> list[child_names]", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"}, + }, + "example": { + "A_C_merged": ["A", "B", "C"], + "B_F_merged": ["B", "F"], + }, + } + } + }, + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def merge_bags_query(): + db: Session = SessionLocal() + try: + body = request.get_json(force=True) or {} + bag_names = body.get("bag_names") + print(bag_names) + + # 基本校验 + if not bag_names or not isinstance(bag_names, list): + return jsonify({"error": "bag_names must be a list"}), 400 + + # 通配符特判 + if bag_names == ["*"]: + data = query_all_joined_bags(db) + else: + data = query_existed_joined_bag(db, bag_names) + + return jsonify(data), 200 + except ValueError as ve: + return jsonify({"error": str(ve)}), 400 + except Exception: + return jsonify({"error": "Internal server error"}), 500 + finally: + db.close() + + +@bp.route("/joined/delete", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["Joined-bag"], + "summary": "Delete merged parent bags and restore child bags", + "description": ( + "Accepts a list of bag names (parent or child) and deletes the corresponding " + "merged relationships, removes parent entries from joined_pangu & bag_list, " + "and resets child bags to active with 'joined' flag cleared." + ), + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "example": ["A_C_merged", "B"], + } + }, + "required": ["bag_names"], + } + } + }, + }, + "responses": { + "200": { + "description": "Deletion successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deleted_parents": { + "type": "array", + "items": {"type": "string"}, + "example": ["A_C_merged"], + }, + }, + } + } + }, + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + }, + } + }, + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def delete_merge_bags(): + """Query merged bags""" + db: Session = SessionLocal() + try: + body = request.get_json(force=True) or {} + bag_names = body.get("bag_names") + if not bag_names or not isinstance(bag_names, list) or len(bag_names) == 0: + return jsonify({"error": "bag_names must be a non-empty list"}), 400 + + data = delete_existed_joined_bag(db, bag_names) + + return jsonify(data), 201 + except ValueError as ve: + return jsonify({"error": str(ve)}), 400 + except Exception as e: + return jsonify({"error": "Internal server error"}), 500 + finally: + db.close() + + +@bp.route("/version", methods=["GET"]) +@swag_from({ + "tags": ["Bag管理"], + "summary": "按软件/硬件版本模糊查询 bag 名称列表", + "description": "支持通配符 *,例如 ?sw=1.2.* 或 ?hw=*rev3*", + "parameters": [ + { + "name": "sw", + "in": "query", + "type": "string", + "required": False, + "description": "软件版本(支持 * 通配)", + }, + { + "name": "hw", + "in": "query", + "type": "string", + "required": False, + "description": "硬件版本(支持 * 通配)", + }, + ], + "responses": { + 200: { + "description": "返回符合条件的 bag 名称列表", + "schema": { + "type": "array", + "items": {"type": "string"}, + "example": ["bag1.bag", "bag2.bag"], + }, + }, + 400: { + "description": "参数错误", + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + }, + }, + }, +}) +def query_by_version(): + sw: Optional[str] = request.args.get("sw") + hw: Optional[str] = request.args.get("hw") + if not sw and not hw: + return jsonify({"error": "至少需提供 sw 或 hw 参数"}), 400 + + # * → % 用于 SQL ILIKE + sw_pattern = sw.replace("*", "%") if sw else None + hw_pattern = hw.replace("*", "%") if hw else None + + db = SessionLocal() + try: + names = service.query_bag_names_by_version( + db, sw_pattern=sw_pattern, hw_pattern=hw_pattern + ) + return jsonify(names), 200 + finally: + db.close() + + +@bp.route("/versions", methods=["GET"]) +@swag_from({ + "tags": ["Bag管理"], + "summary": "获取可用软件/硬件版本列表", + "description": "从 bag_list 去重返回 sw_version/hw_version 列表(过滤空值)。", + "responses": { + 200: { + "description": "版本列表", + "schema": { + "type": "object", + "properties": { + "sw_versions": { + "type": "array", + "items": {"type": "string"}, + "example": ["v1.0", "v1.1", "v2.0"], + }, + "hw_versions": { + "type": "array", + "items": {"type": "string"}, + "example": ["h1.0", "h2.0"], + }, + }, + }, + }, + 500: { + "description": "Internal server error", + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + }, + }, + }, +}) +def list_versions(): + db: Session = SessionLocal() + try: + return jsonify(service.get_distinct_versions(db)), 200 + except Exception: + return jsonify({"error": "Internal server error"}), 500 + finally: + db.close() + + +@bp.route("/vehicles", methods=["GET"]) +@swag_from({ + "tags": ["Bag管理"], + "summary": "获取可用车号列表", + "description": "从 main_pangu 去重返回 vehicle 列表(过滤空值)。", + "responses": { + 200: { + "description": "车号列表", + "schema": { + "type": "object", + "properties": { + "vehicles": { + "type": "array", + "items": {"type": "string"}, + "example": ["PL061763", "PL061764"], + }, + }, + }, + }, + 500: { + "description": "Internal server error", + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + }, + }, + }, +}) +def list_vehicles(): + db: Session = SessionLocal() + try: + return jsonify(service.get_distinct_vehicles(db)), 200 + except Exception: + return jsonify({"error": "Internal server error"}), 500 + finally: + db.close() diff --git a/fst_data_pipeline/apps/root_db_api/src/api/fst.py b/fst_data_pipeline/apps/root_db_api/src/api/fst.py new file mode 100644 index 0000000..0c060b2 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/fst.py @@ -0,0 +1,1295 @@ +from typing import List, Tuple + +from flasgger import swag_from +from flask import Blueprint, jsonify, request +from sqlalchemy.orm import Session + +from fst_data_pipeline.apps.root_db_api.src.core.service import ( + get_bags_by_fst_name, + get_fst, + get_fst_by_id, + get_bags_by_fst_paths, + print_fst_tree, + get_total_bag_sum, + get_bag_names_linked_to_fst, + FSTBagItem, + batch_upsert_fst_bag, + delete_fst_by_fst_name, + upsert_fst_node, + get_all_fst_nodes, + fst_bags_detail, +) +from fst_data_pipeline.apps.root_db_api.src.core.models import FST +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("fst", __name__, url_prefix="/fst") + + +@bp.route("/print_tree", methods=["GET"]) +@swag_from({ + "responses": { + 200: { + "description": "返回FST树结构的预格式化文本", + "schema": { + "type": "string", + "example": "
    Your FST tree output here
    ", + }, + }, + 500: { + "description": "服务器内部错误", + "schema": { + "type": "object", + "properties": { + "error": {"type": "string", "example": "An error occurred"} + }, + }, + }, + }, + "tags": ["FST管理"], + "summary": "获取FST树结构", + "description": "获取FST树结构的预格式化文本展示", +}) +def print_tree(): + try: + tree_output = print_fst_tree() + return jsonify(tree_output), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/stash/print_tree", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "从 fst_stash 获取并构建 FST 树结构", + "description": "根据指定 version,从 fst_stash.content 读取 JSON(要求包含 nodes 数组),并按 /fst/print_tree 的结构返回树。", + "parameters": [ + { + "name": "version", + "in": "query", + "required": True, + "schema": {"type": "string"}, + "description": "版本号,对应 fst_stash.fst_versions", + } + ], + "responses": { + 200: { + "description": "基于 fst_stash 的 FST 树结构", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "label": {"type": "string"}, + "level": {"type": "integer"}, + "scene": { + "type": "string", + "nullable": True, + "description": "节点所属场景,透传自 content.nodes[*].scene", + }, + "children": { + "type": "array", + "items": {"$ref": "#/components/schemas/FSTNode"}, + }, + }, + }, + }, + } + }, + }, + 400: { + "description": "参数或数据格式错误", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + 404: { + "description": "指定版本在 fst_stash 中不存在", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + 500: { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def print_tree_from_stash(): + from fst_data_pipeline.apps.root_db_api.src.core.service import get_fst_stash + + version = request.args.get("version") + if not version: + return jsonify({"error": "Query parameter 'version' is required"}), 400 + + db: Session = SessionLocal() + try: + stash = get_fst_stash(db, version=version) + if not stash or not stash.content: + return jsonify({"error": f"Stash not found or empty for version '{version}'"}), 404 + + content = stash.content or {} + nodes_data = content.get("nodes") + if not isinstance(nodes_data, list): + return jsonify({"error": "Stash content must contain a 'nodes' array"}), 400 + + node_map = {} + for item in nodes_data: + try: + node_id = item.get("id") + name = item.get("name") + if node_id is None or name is None: + continue + node_map[node_id] = { + "id": name, + "label": name, + "scene": item.get("scene"), + "level": 0, + "children": [], + } + except Exception: + continue + + roots = [] + for item in nodes_data: + try: + node_id = item.get("id") + parent_id = item.get("parentId") + if node_id not in node_map: + continue + node = node_map[node_id] + if parent_id is not None and parent_id in node_map: + node_map[parent_id]["children"].append(node) + else: + roots.append(node) + except Exception: + continue + + def set_level(node, lv): + node["level"] = lv + for ch in node["children"]: + set_level(ch, lv + 1) + + for r in roots: + set_level(r, 0) + + return jsonify(roots), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +@bp.route("/all_nodes", methods=["GET"]) +@swag_from({ + "tags": ["FST管理"], + "summary": "获取所有FST节点", + "description": "一次性返回全部 FST 节点", + "responses": { + "200": { + "description": "节点列表", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "label": {"type": "string"}, + "parent_id": { + "type": "integer", + "nullable": True, + }, + }, + }, + }, + }, + } + } + }, + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": False}, + "error": { + "type": "object", + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"}, + "details": { + "type": "object", + "additionalProperties": True, + }, + }, + }, + }, + } + } + }, + }, + }, +}) +def get_all_nodes(): + """获取全部 FST 节点""" + try: + with SessionLocal() as db: + nodes = get_all_fst_nodes(db) + data = [ + {"id": n.id, "label": n.name, "parent_id": n.parent_id} for n in nodes + ] + return jsonify({"success": True, "data": data}), 200 + except Exception as e: + return ( + jsonify({ + "success": False, + "error": { + "code": "INTERNAL_ERROR", + "message": "Failed to fetch FST nodes", + "details": {"exception": str(e)}, + }, + }), + 500, + ) + + +# ---------- POST /fst/stash ---------- +@bp.route("/stash", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Save FST stash content for a version", + "description": "Create or update stash content for a specific version. Version must exist in version_list.", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["version", "content"], + "properties": { + "version": {"type": "string", "example": "v1.0.0"}, + "content": {"type": "object", "description": "JSON content to stash"}, + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Stash saved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "version": {"type": "string"}, + "updated_time": {"type": "string"}, + }, + } + } + }, + }, + "400": {"description": "Invalid input or version not found"}, + "500": {"description": "Internal server error"}, + }, +}) +def save_fst_stash(): + from fst_data_pipeline.apps.root_db_api.src.core.service import upsert_fst_stash + + db: Session = SessionLocal() + try: + payload = request.get_json(force=True) or {} + version = payload.get("version") + content = payload.get("content") + + if not version: + return jsonify({"error": "Field 'version' is required"}), 400 + + stash = upsert_fst_stash( + db, + version=version, + content=content, + use_node_optimistic_lock=False, + ) + db.commit() + + return jsonify({ + "id": stash.id, + "version": stash.fst_versions, + "updated_time": stash.updated_time.isoformat() if stash.updated_time else None, + }), 200 + + except ValueError as ve: + db.rollback() + return jsonify({"error": str(ve)}), 400 + except Exception as e: + db.rollback() + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +# ---------- GET /fst/stash/ ---------- +@bp.route("/stash/", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Get FST stash content by version", + "parameters": [ + { + "name": "version", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "Version string (e.g. v1.0.0)", + } + ], + "responses": { + "200": { + "description": "Stash content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "version": {"type": "string"}, + "content": {"type": "object"}, + "updated_time": {"type": "string"}, + }, + } + } + }, + }, + "404": {"description": "Stash not found for this version"}, + "500": {"description": "Internal server error"}, + }, +}) +def get_fst_stash_by_version(version: str): + from fst_data_pipeline.apps.root_db_api.src.core.service import ( + get_fst_stash, + normalize_stash_node_versions, + ) + + db: Session = SessionLocal() + try: + stash = get_fst_stash(db, version=version) + if not stash: + return jsonify({"error": f"Stash not found for version '{version}'"}), 404 + + return jsonify({ + "id": stash.id, + "version": stash.fst_versions, + "content": normalize_stash_node_versions(stash.content), + "updated_time": stash.updated_time.isoformat() if stash.updated_time else None, + }), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +@bp.route("/stash/update", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Create or update FST stash content (by version)", + "description": "参考 /fst/update 的风格,根据传入的 version 对 fst_stash 表做 UPSERT 操作,写入完整 JSON 内容。", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["version", "content"], + "properties": { + "version": { + "type": "string", + "example": "MB Driving FST V1", + "description": "版本号,对应 version_list.version 与 fst_stash.fst_versions", + }, + "content": { + "type": "object", + "description": "要写入 fst_stash.content 的完整 JSON(例如包含 meta 与 nodes)。", + }, + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Stash created or updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "version": {"type": "string"}, + "updated_time": {"type": "string"}, + }, + } + } + }, + }, + "400": { + "description": "Invalid input or version not found in version_list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string"}, + }, + } + } + }, + }, + "409": { + "description": "Node optimistic lock conflict", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string"}, + "code": {"type": "string", "example": "STASH_NODE_VERSION_CONFLICT"}, + "conflicts": {"type": "array", "items": {"type": "object"}}, + }, + } + } + }, + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string"}, + }, + } + } + }, + }, + }, +}) +def upsert_fst_stash_api(): + from fst_data_pipeline.apps.root_db_api.src.core.service import ( + StashNodeVersionConflictError, + upsert_fst_stash, + ) + + db: Session = SessionLocal() + try: + payload = request.get_json(force=True) or {} + version = payload.get("version") + content = payload.get("content") + + if not version: + return jsonify({"error": "Field 'version' is required"}), 400 + + stash = upsert_fst_stash( + db, + version=version, + content=content, + use_node_optimistic_lock=True, + ) + db.commit() + + return jsonify({ + "id": stash.id, + "version": stash.fst_versions, + "updated_time": stash.updated_time.isoformat() if stash.updated_time else None, + }), 200 + except StashNodeVersionConflictError as ce: + db.rollback() + return ( + jsonify({ + "error": "Node optimistic lock conflict", + "code": "STASH_NODE_VERSION_CONFLICT", + "conflicts": ce.conflicts, + }), + 409, + ) + except ValueError as ve: + db.rollback() + return jsonify({"error": str(ve)}), 400 + except Exception as e: + db.rollback() + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +@bp.route("/stash/diff", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Diff two FST stash versions", + "description": "对比两个 fst_stash 版本的 nodes 数组,按树路径输出新增/删除/修改的节点摘要。", + "parameters": [ + { + "name": "from_version", + "in": "query", + "required": True, + "schema": {"type": "string"}, + "description": "源版本号,将作为对比基准。", + }, + { + "name": "to_version", + "in": "query", + "required": True, + "schema": {"type": "string"}, + "description": "目标版本号,通常是准备 Release 的版本。", + }, + ], + "responses": { + "200": { + "description": "Diff result between two stash versions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "summary": { + "type": "object", + "properties": { + "from_version": {"type": "string"}, + "to_version": {"type": "string"}, + "added_count": {"type": "integer"}, + "removed_count": {"type": "integer"}, + "changed_count": {"type": "integer"}, + }, + }, + "added": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "node": {"type": "object"}, + }, + }, + }, + "removed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "node": {"type": "object"}, + }, + }, + }, + "changed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "before": {"type": "object"}, + "after": {"type": "object"}, + }, + }, + }, + }, + }, + } + }, + }, + "400": { + "description": "Invalid parameters or stash content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string"}, + }, + } + } + }, + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string"}, + }, + } + } + }, + }, + }, +}) +def diff_fst_stash(): + from fst_data_pipeline.apps.root_db_api.src.core.service import diff_fst_stash_versions + + from_version = request.args.get("from_version") + to_version = request.args.get("to_version") + + db: Session = SessionLocal() + try: + result = diff_fst_stash_versions( + db, + from_version=from_version, + to_version=to_version, + ) + return jsonify(result), 200 + except ValueError as ve: + db.rollback() + return jsonify({"error": str(ve)}), 400 + except Exception as e: + db.rollback() + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +# Get bags by FST names +@bp.route("/bags/nodes", methods=["POST"]) +def get_bags_by_fst_nodes_batch(): + """ + POST /bags/nodes?page=1&per_page=20 + Body: ["fst1","fst2",...] + 返回 + { + "items": [{"bag_name":"xxx.bag","sts":"fst1"}, ...], + "page":1, + "per_page":20, + "total": 123 + } + """ + fst_names: List[str] = request.get_json(force=True, silent=True) or [] + if not isinstance(fst_names, list): + return jsonify({"error": "Body must be a list of FST names"}), 400 + + try: + page = max(int(request.args.get("page", 1)), 1) + per_page = min(max(int(request.args.get("per_page", 20)), 1), 100) + except ValueError: + return jsonify({"error": "page & per_page must be positive integers"}), 400 + + db = SessionLocal() + try: + records: List[Tuple[str, str]] = [] + for fst_name in fst_names: + bags = get_bags_by_fst_name(db, name=fst_name) + for b in bags: + records.append((b.name, fst_name)) + + # 2. 分页 + total = len(records) + start = (page - 1) * per_page + end = start + per_page + items = [{"bag_name": bag, "sts": fst} for bag, fst in records[start:end]] + + return jsonify({ + "items": items, + "page": page, + "per_page": per_page, + "total": total, + }) + finally: + db.close() + + +# Get bags by FST name and its subnodes +@bp.route("/bags/path/", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Get bags under an FST and its descendants", + "description": "List of bags associated with the specified FST and its child nodes", + "parameters": [ + { + "name": "name", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "The name of the FST", + } + ], + "responses": { + "200": { + "description": "Bags grouped by FST node name", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": { + "type": "string", + "example": "Bag Name", + }, + } + }, + }, + } + } + }, + }, + "404": {"description": "Specified FST not found"}, + }, +}) +def get_bags_by_fst_path(name: str): + """Return bags grouped by FST node name.""" + db: Session = SessionLocal() + try: + grouped = get_bags_by_fst_paths(db, name=name) # dict + # 扁平化所有 bag names + # all_bags = sorted({bag for node in grouped.values() for bag in node["bags"]}) + return jsonify(grouped) + finally: + db.close() + + +# Get FST by name +@bp.route("/", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", # 新增 + "tags": ["FST管理"], + "summary": "Get FST details by name", + "parameters": [ + { + "name": "name", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "The name of the FST", + } + ], + "responses": { + "200": { + "description": "Get details of the FST", + "content": { # 新增 content + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string", "example": "FST Name"}, + "parent_node": { + "type": "string", + "example": "Parent FST Name", + }, + "linked_bags_sum": {"type": "integer", "example": 5}, + }, + } + } + }, + }, + "404": {"description": "Specified FST not found"}, + }, +}) +def get_fst_node_by_name(name: str): + """Get FST details by name, returning parent node's name""" + db: Session = SessionLocal() + try: + fst = get_fst(db, name=name) + if fst is None: + return jsonify({"error": "FST not found"}), 404 + + # 1. 计算当前节点及其所有后代的 bag_sum 总和 + total_sum = get_total_bag_sum(db, root_name=fst.name) + + # 2. 获取父节点名字 + parent_name = None + if fst.parent_id is not None: + parent = get_fst_by_id(db, id=fst.parent_id) + if parent: + parent_name = parent.name + + return jsonify({ + "name": fst.name, + "parent_node": parent_name, + "linked_bags_sum": total_sum, + }) + finally: + db.close() + + +@bp.route("/baglist", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "List all FST tree with bag details", + "description": ( + "Returns complete FST tree structure with associated bags including " + "event_start_time, event_end_time, and comments from FST-Bag relationships." + ), + "responses": { + "200": { + "description": "FST tree structure with bag details", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": {"type": "integer", "example": 1}, + "name": { + "type": "string", + "example": "highway_scenarios", + }, + "parent_id": { + "type": "integer", + "nullable": True, + "example": None, + }, + "parent_name": { + "type": "string", + "nullable": True, + "example": None, + }, + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": { + "type": "string", + "example": "sample_01.bag", + }, + "event_start_time": { + "type": "integer", + "nullable": True, + "example": 1000, + }, + "event_end_time": { + "type": "integer", + "nullable": True, + "example": 2000, + }, + "comments": { + "type": "string", + "nullable": True, + "example": "Highway driving scenario", + }, + }, + }, + }, + "children": { + "type": "object", + "description": "Child FST nodes with same structure", + "additionalProperties": {"$ref": "#"}, + }, + }, + }, + } + }, + }, + } + }, +}) +def list_bag_names_with_fst(): + """Return complete FST tree with bag details including timing and comments.""" + db: Session = SessionLocal() + try: + rows = get_bag_names_linked_to_fst(db) + names = sorted({row[0] for row in rows}) + return jsonify(names) + finally: + db.close() + + +@bp.route("/bags/update", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Batch upsert FST-Bag associations", + "description": ( + "Receives an array of records to create or update FST-Bag links." + "- Each record is validated independently; failures do **not** block the others." + "- Validation includes node existence and parent-child chain integrity." + "- If a bag-FST link already exists, only the time range is updated and **bag_sum is unchanged**." + "- If the link does **not** exist, a new row is inserted and the corresponding FST node's **bag_sum is incremented by 1**." + "- Only the **lowest valid node** in the chain is linked to the bag." + ), + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "required": ["bag_name", "nodes", "start", "end"], + "properties": { + "bag_name": { + "type": "string", + "example": "sample_01.bag", + }, + "nodes": { + "type": "array", + "description": "Ordered list of FST node names representing the hierarchy (parent → child → …). Empty strings are ignored.", + "items": {"type": "string"}, + "example": ["root", "scene", "lane", ""], + }, + "start": { + "type": "integer", + "description": "Event start time", + "example": 5, + }, + "end": { + "type": "integer", + "description": "Event end time", + "example": 10, + }, + }, + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Per-bag processing results", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": { + "type": "string", + "example": "sample_01.bag", + }, + "success": {"type": "boolean", "example": True}, + "reason": { + "type": "string", + "nullable": True, + "example": None, + }, + }, + }, + } + } + }, + } + }, +}) +def update_fst_bag(): + """ + 批量新增 / 更新 FST-Bag 关联 + 接收 JSON 数组,返回逐条成功/失败信息 + """ + db: Session = SessionLocal() + try: + body = request.get_json(force=True) or [] + items = [FSTBagItem(**rec) for rec in body] + print(items) + result = batch_upsert_fst_bag(db, items) + print(result) + db.commit() + return jsonify([r.__dict__ for r in result]), 200 + except Exception as e: + db.rollback() + return jsonify({"error": str(e)}), 400 + finally: + db.close() + + +# ---------- POST /fst/update ---------- +@bp.route("/update", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Create or update an FST node", + "description": ( + "Create a new FST node or update an existing one identified by `name`. " + "Provide parent via `parent_name`, which must exist." + ), + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "id": {"type": "integer", "nullable": True}, + "name": {"type": "string", "example": "lane_001"}, + "parent_name": { + "type": "string", + "example": "scene", + "nullable": True, + }, + "reserved_json": { + "type": "object", + "example": {"meta": "data"}, + "nullable": True, + }, + "bag_sum": { + "type": "integer", + "example": 0, + "nullable": True, + }, + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Node created or updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "parent_id": {"type": "integer", "nullable": True}, + "reserved_json": {"type": "object", "nullable": True}, + "bag_sum": {"type": "integer"}, + }, + } + } + }, + }, + "400": {"description": "Invalid input or parent not found"}, + "500": {"description": "Internal server error"}, + }, +}) +def upsert_fst(): + db: Session = SessionLocal() + try: + payload = request.get_json(force=True) or {} + node_id = payload.get("id") or payload.get("node_id") + name = payload.get("name") + if not name: + return jsonify({"error": "Field 'name' is required"}), 400 + + parent_name = payload.get("parent_name") or payload.get("parentName") + parent_id = payload.get("parent_id") + if parent_id is None: + parent_id = payload.get("parentId") + if parent_name is None and parent_id is not None: + try: + parent_id_int = int(parent_id) + except (TypeError, ValueError): + return jsonify({"error": "Invalid 'parent_id'"}), 400 + parent = db.query(FST).filter(FST.id == parent_id_int).first() + if not parent: + return jsonify({"error": f"Parent FST id {parent_id_int} not found"}), 400 + parent_name = parent.name + + node = upsert_fst_node( + db, + node_id=int(node_id) if node_id is not None else None, + name=name, + parent_name=parent_name, + reserved_json=payload.get("reserved_json"), + bag_sum=payload.get("bag_sum"), + ) + db.commit() + return jsonify({ + "id": node.id, + "name": node.name, + "parent_id": node.parent_id, + "reserved_json": node.reserved_json, + "bag_sum": node.bag_sum, + }), 200 + except ValueError as ve: + db.rollback() + return jsonify({"error": str(ve)}), 400 + except Exception as e: + db.rollback() + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +# ---------- DELETE /fst/ ---------- +@bp.route("/", methods=["DELETE"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["FST管理"], + "summary": "Logically delete an FST node by name", + "description": "Mark the specified FST node as deleted (is_delete = true).", + "parameters": [ + { + "name": "name", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "The name of the FST node to delete", + } + ], + "responses": { + "200": {"description": "Node marked as deleted successfully"}, + "404": {"description": "FST node not found"}, + "500": {"description": "Internal server error"}, + }, +}) +def delete_fst_by_name(name: str): + db: Session = SessionLocal() + try: + delete_fst_by_fst_name(db, name) + db.commit() + return jsonify({"ok": True}), 200 + except ValueError as ve: + db.rollback() + return jsonify({"error": str(ve)}), 404 + except Exception as e: + db.rollback() + return jsonify({"error": str(e)}), 500 + finally: + db.close() + + +@bp.route("//bags", methods=["GET"]) +@swag_from({ + "tags": ["FST管理"], + "summary": "Get bags & Pangu details under an FST and its descendants", + "description": "List all bags (with Pangu details) associated with the specified FST and its child nodes", + "parameters": [ + { + "name": "name", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "The name of the FST", + } + ], + "responses": { + "200": { + "description": "Bags grouped by FST node name", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bagName": {"type": "string"}, + "decodedDir": {"type": "string"}, + "tosPath": {"type": "string"}, + "mVizUrl": {"type": "string"}, + "mbVizUrl": {"type": "string"}, + "comment": {"type": "string"}, + }, + }, + } + }, + }, + }, + }, + } + } + }, + }, + "404": { + "description": "Specified FST not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": False}, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "FST_NOT_FOUND", + }, + "message": { + "type": "string", + "example": "Specified FST not found", + }, + "details": { + "type": "object", + "additionalProperties": True, + }, + }, + }, + }, + } + } + }, + }, + }, +}) +def get_fst_bags_detail(name: str): + """一次性返回 FST 及其子节点下所有 bag 的 Pangu 详情""" + try: + with SessionLocal() as db: + data = fst_bags_detail(db, name) + if not data: + return ( + jsonify({ + "success": False, + "error": { + "code": "FST_NOT_FOUND", + "message": "Specified FST not found", + "details": {}, + }, + }), + 404, + ) + return jsonify({"success": True, "data": data}), 200 + except Exception as e: + return ( + jsonify({ + "success": False, + "error": { + "code": "INTERNAL_ERROR", + "message": "Failed to fetch bags", + "details": {"exception": str(e)}, + }, + }), + 500, + ) diff --git a/fst_data_pipeline/apps/root_db_api/src/api/geometry.py b/fst_data_pipeline/apps/root_db_api/src/api/geometry.py new file mode 100644 index 0000000..4431a3c --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/geometry.py @@ -0,0 +1,97 @@ +from flasgger import swag_from +from flask import Blueprint, jsonify, request +from sqlalchemy.orm import Session +from sqlalchemy.sql import text + +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("geometry", __name__, url_prefix="/geometry") + + +# 根据多个 rosbag 名称获取几何信息 +@bp.route("/", methods=["POST"]) +@swag_from({ + "tags": ["几何数据管理"], + "summary": "根据rosbag名称获取几何信息", + "description": "根据多个rosbag名称查询对应的几何轨迹信息", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["rosbag_names"], + "properties": { + "rosbag_names": { + "type": "array", + "description": "要查询的rosbag名称列表", + "items": { + "type": "string", + "example": "bag1.bag", + }, + } + }, + } + } + }, + }, + "responses": { + "200": { + "description": "返回指定rosbag名称的几何轨迹信息", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": {"type": "number"}, + "example": [12.345678, 12.345678, 0.0], + "description": "经度、纬度、高度坐标", + }, + }, + } + } + }, + }, + "400": {"description": "缺少rosbag_names参数"}, + "404": {"description": "未找到指定名称的rosbag"}, + }, +}) +def get_geometry_info_by_rosbags(): + """Get geometry info by multiple rosbag names""" # 根据多个 rosbag 名称获取几何信息 + db: Session = SessionLocal() + rosbag_names = request.json.get("rosbag_names", []) + + try: + if not rosbag_names: + return jsonify({}), 400 # 如果没有提供rosbag名称,返回400错误 + + # 查询多个rosbag名称对应的几何信息 + sql = text(""" + SELECT rosbag_name, + ARRAY( + SELECT ARRAY[ + ST_X(pt.geom), ST_Y(pt.geom), + COALESCE(ST_Z(pt.geom), 0.0) ] + FROM unnest(gnss_downsampled_points) AS pt(geom) + ) AS lon_lat_alt + FROM geometry_info + WHERE rosbag_name IN :rosbag_names + """) + + # 使用execute并传入多个rosbag名称 + result = db.execute(sql, {"rosbag_names": tuple(rosbag_names)}).fetchall() + + if not result: + return jsonify({}), 404 # 如果未找到结果,返回404 + + # 构建以键值形式返回的结果 + response = {row.rosbag_name: row.lon_lat_alt for row in result} + return jsonify(response) + + finally: + db.close() diff --git a/fst_data_pipeline/apps/root_db_api/src/api/gt.py b/fst_data_pipeline/apps/root_db_api/src/api/gt.py new file mode 100644 index 0000000..e09b2d2 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/gt.py @@ -0,0 +1,403 @@ +import os +import re +import shutil +import subprocess +from pathlib import Path + +import yaml +from flasgger import swag_from +from flask import Blueprint, jsonify, request + +from fst_data_pipeline.apps.root_db_api.src.core.service import ( + get_all_gt_types, + get_bags_by_gt_name, + link_bags_to_gt_with_stat, +) +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("gt", __name__, url_prefix="/gt") + + +# ------------------------- 1. 查询所有 GT 种类 ------------------------- +@bp.route("/types", methods=["GET"]) +@swag_from({ + "tags": ["真值数据管理"], + "summary": "获取所有 GT 种类", + "responses": { + "200": { + "description": "GT 类型列表", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "path": {"type": "string"}, + "comment": {"type": "string"}, + }, + }, + } + } + }, + } + }, +}) +def list_gt_types(): + db = SessionLocal() + try: + data = get_all_gt_types(db) + finally: + db.close() + return jsonify(data) + + +# ------------------------- 2. 根据 GT 名称查询关联的 bag ------------------------- +@bp.route("//bags", methods=["GET"]) +@swag_from({ + "tags": ["真值数据管理"], + "summary": "根据 GT 名称获取关联的 bag 列表", + "parameters": [ + { + "name": "gt_name", + "in": "path", + "type": "string", + "required": True, + "description": "GT 唯一名称", + } + ], + "responses": { + "200": { + "description": "bag 列表", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "tile_id": {"type": "string"}, + "is_decoded": {"type": "boolean"}, + "project_id": {"type": "integer"}, + }, + }, + } + } + }, + }, + "404": { + "description": "GT 不存在", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string", "example": "GT not found"}, + }, + } + } + }, + }, + }, +}) +def list_bags_by_gt_name(gt_name: str): + db = SessionLocal() + try: + bags = get_bags_by_gt_name(db, gt_name) + if bags is None: + return jsonify({"error": "GT not found"}), 404 + finally: + db.close() + return jsonify(bags) + + +# ------------------------- 3. 批量关联 bag 到指定 GT(纯路径+body)------------------------- +@bp.route("//bags", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["真值数据管理"], + "summary": "批量将 bag 列表关联到指定 GT", + "description": "路径传入 GT 名称,body 传入 bag 列表,完成关联后返回统计结果。", + "parameters": [ + { + "name": "gt_name", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "GT 唯一名称", + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "要关联的 bag 名列表", + "example": ["a.bag", "b.bag"], + } + }, + } + } + }, + }, + "responses": { + "201": { + "description": "关联完成", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gt_name": {"type": "string"}, + "total": {"type": "integer"}, + "succeeded": {"type": "integer"}, + "skipped": {"type": "integer"}, + "failed": {"type": "integer"}, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "status": { + "type": "string", + "enum": [ + "success", + "skipped", + "failed", + ], + }, + "message": {"type": "string"}, + }, + }, + }, + }, + } + } + }, + }, + "400": {"description": "参数缺失或空列表"}, + "404": {"description": "GT 不存在"}, + }, +}) +def batch_link_bags_to_gt(gt_name: str): + """批量给 bag 关联指定 GT(仅 gt_name + body)""" + body = request.get_json(silent=True) + if not body: + return jsonify({"error": "JSON body required"}), 400 + + bag_names = body.get("bag_names") + if not isinstance(bag_names, list) or len(bag_names) == 0: + return jsonify({ + "error": "bag_names(list) is required and cannot be empty" + }), 400 + db = SessionLocal() + try: + # 复用刚才的 service,去掉 creator 参数 + stat = link_bags_to_gt_with_stat(db, gt_name, bag_names) + if stat is None: + return jsonify({"error": "GT not found"}), 404 + return jsonify(stat), 201 + finally: + db.close() + + +# ---------- 1. 任务类型列表 ---------- +@bp.route("/tasks/types", methods=["GET"]) +@swag_from({ + "tags": ["真值数据管理"], + "summary": "获取任务类型列表", + "parameters": [ + {"name": "repo", "in": "query", "type": "string", "default": "repo"}, + {"name": "branch", "in": "query", "type": "string", "default": "main"}, + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "data": { + "type": "object", + "properties": { + "task_types": { + "type": "array", + "items": {"type": "string"}, + }, + "branch": {"type": "string"}, + "repo": {"type": "string"}, + "total_count": {"type": "integer"}, + }, + }, + }, + } + } + }, + }, + "404": { + "description": "分支不存在", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "error": { + "type": "object", + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"}, + "details": {"type": "object"}, + }, + }, + }, + } + } + }, + }, + }, +}) +def list_task_types(): + repo = request.args.get("repo", "repo") + branch = request.args.get("branch", "main") + + # ------ mock 逻辑 ------ + if branch == "non-existent-branch": + return jsonify({ + "success": False, + "error": { + "code": "BRANCH_NOT_FOUND", + "message": "指定的分支不存在", + "details": {"branch": branch, "repo": repo}, + }, + }), 404 + + mock_types = [ + "tsa", + "lane_change", + ] + return jsonify({ + "success": True, + "data": { + "task_types": mock_types, + "branch": branch, + "repo": repo, + "total_count": len(mock_types), + }, + }), 200 + + +# ---------- 2. 任务真值路径 ---------- +@bp.route("/tasks//truth-path", methods=["GET"]) +@swag_from({ + "tags": ["真值数据管理"], + "summary": "根据任务类型获取真值路径", + "parameters": [ + {"name": "task_type", "in": "path", "required": True, "type": "string"}, + {"name": "repo", "in": "query", "type": "string", "default": "repo"}, + {"name": "branch", "in": "query", "type": "string", "default": "main"}, + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "data": { + "type": "object", + "properties": { + "task_type": {"type": "string"}, + "truth_path": {"type": "string"}, + "branch": {"type": "string"}, + "repo": {"type": "string"}, + "metadata": { + "type": "object", + "properties": { + "file_size": {"type": "integer"}, + "last_modified": {"type": "string"}, + "format": {"type": "string"}, + "encoding": {"type": "string"}, + }, + }, + }, + }, + }, + } + } + }, + }, + "404": { + "description": "任务类型不存在", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "error": { + "type": "object", + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"}, + "details": {"type": "object"}, + }, + }, + }, + } + } + }, + }, + }, +}) +def task_truth_path(task_type: str): + repo = request.args.get("repo", "repo") + branch = request.args.get("branch", "main") + + # ------ mock 逻辑 ------ + valid_tasks = [ + "tsa", + "lane_change", + ] + if task_type not in valid_tasks: + return jsonify({ + "success": False, + "error": { + "code": "TASK_TYPE_NOT_FOUND", + "message": "指定的任务类型不存在", + "details": {"task_type": task_type, "branch": branch, "repo": repo}, + }, + }), 404 + + mock_path = f"/data1/MB/shared/gt/{task_type}/" + return jsonify({ + "success": True, + "data": { + "task_type": task_type, + "truth_path": mock_path, + "branch": branch, + "repo": repo, + "metadata": { + "file_size": 2048576, + "last_modified": "2024-12-01T10:30:00Z", + "format": "json", + "encoding": "utf-8", + }, + }, + }), 200 diff --git a/fst_data_pipeline/apps/root_db_api/src/api/projects.py b/fst_data_pipeline/apps/root_db_api/src/api/projects.py new file mode 100644 index 0000000..90b85bf --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/projects.py @@ -0,0 +1,58 @@ +from flasgger import Swagger, swag_from +from flask import Blueprint, jsonify +from sqlalchemy.orm import Session + +from fst_data_pipeline.apps.root_db_api.src.core.service import get_projects +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("projects", __name__, url_prefix="/projects") + +# Initialize Flasgger +swagger = Swagger() + + +# 获取所有项目 +@bp.route("/all", methods=["GET"]) +@swag_from({ + "tags": ["项目管理"], + "summary": "获取所有项目", + "description": "获取系统中所有项目的列表信息", + "responses": { + 200: { + "description": "项目列表", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1, + "description": "项目ID", + }, + "name": { + "type": "string", + "example": "自动驾驶数据集", + "description": "项目名称", + }, + }, + }, + }, + }, + 500: { + "description": "服务器内部错误", + "schema": { + "type": "object", + "properties": {"error": {"type": "string", "example": "发生错误"}}, + }, + }, + }, +}) +def read_projects(): + """Get all projects""" + db: Session = SessionLocal() + try: + projects = get_projects(db) # 现在返回 list[dict] + finally: + db.close() + return jsonify(projects) diff --git a/fst_data_pipeline/apps/root_db_api/src/api/recompute.py b/fst_data_pipeline/apps/root_db_api/src/api/recompute.py new file mode 100644 index 0000000..efc682a --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/recompute.py @@ -0,0 +1,244 @@ +from flasgger import swag_from +from flask import Blueprint, request, jsonify + +from fst_data_pipeline.apps.root_db_api.src.core.service import ( + get_bag_recompute_versions, + get_storage_paths_batch, +) +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("recompute", __name__, url_prefix="/recompute") + + +# 接口1: 批量获取指定bag列表的所有recompute版本 +@bp.route("/versions", methods=["POST"]) +@swag_from({ + "tags": ["重计算管理"], + "summary": "批量获取bag的重计算版本", + "description": "根据bag名称列表,返回每个bag对应的所有重计算版本", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "bag名称列表", + "example": ["bag1.bag", "bag2.bag", "bag3.bag"], + } + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Bag versions mapping", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"}, + }, + "example": { + "bag1.bag": ["v1.0", "v1.1", "v2.0"], + "bag2.bag": ["v1.0", "v2.0"], + "bag3.bag": [], + }, + } + } + }, + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def get_bag_versions(): + """接口1: 给定指定的rosbag name的列表,返回每个包对应的recompute的全部版本""" + try: + data = request.get_json() + if not data or "bag_names" not in data: + return jsonify({"error": "bag_names list required"}), 400 + + bag_names = data["bag_names"] + if not isinstance(bag_names, list) or len(bag_names) == 0: + return jsonify({"error": "bag_names must be a non-empty list"}), 400 + + db = SessionLocal() + try: + bag_versions = get_bag_recompute_versions(db, bag_names) + return jsonify(bag_versions), 200 + + finally: + db.close() + + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +# 接口2: 批量获取指定bag和版本的文件位置 +@bp.route("/storage", methods=["POST"]) +@swag_from({ + "tags": ["重计算管理"], + "summary": "批量获取bag和版本的存储路径", + "description": "根据bag名称和重计算版本,返回对应的文件存储位置", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["requests"], + "properties": { + "requests": { + "type": "array", + "items": { + "type": "object", + "required": ["bag_name", "recompute_version"], + "properties": { + "bag_name": { + "type": "string", + "description": "Bag name", + }, + "recompute_version": { + "type": "string", + "description": "Recompute version", + }, + }, + }, + "description": "List of bag name and version pairs", + "example": [ + { + "bag_name": "bag1.bag", + "recompute_version": "v1.0", + }, + { + "bag_name": "bag2.bag", + "recompute_version": "v1.1", + }, + ], + } + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Storage paths for each bag and version pair", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"}, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "result_id": {"type": "integer"}, + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"}, + "result_type": {"type": "string"}, + "storage_path": {"type": "string"}, + "status": {"type": "string"}, + "created_time": { + "type": "string", + "format": "date-time", + }, + "reserved_json": {"type": "object"}, + }, + }, + }, + }, + }, + } + } + }, + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + } + }, + }, + }, +}) +def get_storage_paths(): + """接口2: 给定指定的rosbag name和recompute版本列表,批量查询数据库返回对应的文件位置""" + try: + data = request.get_json() + if not data or "requests" not in data: + return jsonify({"error": "requests list required"}), 400 + + requests = data["requests"] + if not isinstance(requests, list) or len(requests) == 0: + return jsonify({"error": "requests must be a non-empty list"}), 400 + + # 验证每个请求的格式 + for req in requests: + if ( + not isinstance(req, dict) + or "bag_name" not in req + or "recompute_version" not in req + ): + return jsonify({ + "error": "Each request must contain bag_name and recompute_version" + }), 400 + + db = SessionLocal() + try: + results = get_storage_paths_batch(db, requests) + return jsonify(results), 200 + + finally: + db.close() + + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/fst_data_pipeline/apps/root_db_api/src/api/tags.py b/fst_data_pipeline/apps/root_db_api/src/api/tags.py new file mode 100644 index 0000000..ef600b0 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/tags.py @@ -0,0 +1,274 @@ +from flasgger import Swagger, swag_from +from flask import Blueprint, jsonify, request +from sqlalchemy.orm import Session + +from fst_data_pipeline.apps.root_db_api.src.core.service import ( + get_tags, + get_tags_by_creator, + query_bags_by_tags, + apply_reserved_tag_with_creator, +) +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("tags", __name__, url_prefix="/tags") + +# Initialize Flasgger +swagger = Swagger() + + +# Get all tags +@bp.route("/all", methods=["GET"]) +@swag_from({ + "tags": ["标签管理"], + "summary": "获取所有标签", + "description": "获取所有标签,包含名称、类型和创建者信息", + "responses": { + 200: { + "description": "标签列表", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "数据质量", + "description": "标签名称", + }, + "type": { + "type": "string", + "example": "质量", + "description": "标签类型", + }, + "creator": { + "type": "string", + "example": "admin@example.com", + "description": "创建者", + }, + }, + }, + }, + } + }, +}) +def read_tags(): + """Fetch all tags""" # Get all tags + db: Session = SessionLocal() + tags = get_tags(db) + db.close() + return jsonify(tags) + + +@bp.route("/bags", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["标签管理"], + "summary": "根据标签名称获取bag文件", + "description": "支持交集(and)或并集(or)查询rosbag", + "parameters": [ + { + "name": "tags", + "in": "query", + "required": True, + "schema": {"type": "string"}, + "description": "逗号分隔的标签名,如 'A,B,C'", + }, + { + "name": "op", + "in": "query", + "required": False, + "schema": {"type": "string", "enum": ["and", "or"], "default": "and"}, + "description": "and=交集,or=并集", + }, + ], + "responses": { + "200": { + "description": "bag文件列表", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + } + }, + } + } + }, + }, + "400": {"description": "参数错误"}, + }, +}) +def get_bags_by_tags(): + tag_str = request.args.get("tags", "").strip() + op = request.args.get("op", "and").strip().lower() + + if not tag_str: + return jsonify({"bags": []}), 400 + tag_list = [t.strip() for t in tag_str.split(",") if t.strip()] + if op not in {"and", "or"}: + return jsonify({"bags": []}), 400 + + db = SessionLocal() + try: + bags = query_bags_by_tags(db, tag_list=tag_list, op=op) + return jsonify({"bags": bags}) + finally: + db.close() + + +# Get tags by creator +@bp.route("/creators/", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", # 新增 + "tags": ["标签管理"], + "summary": "根据创建者获取标签", + "description": "获取指定创建者创建的所有标签", + "parameters": [ + { + "name": "creator", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "创建者标识符(如用户名或邮箱)", + } + ], + "responses": { + "200": { + "description": "List of tag names", + "content": { # 新增:用 content 包装 + "application/json": { + "schema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string", "example": "Sales"}, + } + }, + } + } + }, + }, + "404": {"description": "No tags found for the specified creator"}, + }, +}) +def get_tags_by_creator_name(creator): + """Fetch tags by creator""" # Get tags by creator + db: Session = SessionLocal() + tags = get_tags_by_creator(db, creator=creator) + db.close() + return jsonify({ + "tags": [t["name"] for t in tags] + }) # Changed from "creators" to "tags" for clarity + + +# --------------- 5. 批量打标签(含 creator 参数)---------------- +@bp.route("//bags", methods=["POST"]) +@swag_from({ + "openapi": "3.0.0", + "tags": ["标签管理"], + "summary": "批量给 bag 打指定标签(含 creator)", + "description": "路径传入标签名,body 传入 bag 列表,query 传入 creator,完成打标签后返回统计结果。", + "parameters": [ + { + "name": "tag_name", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "标签名称", + }, + { + "name": "creator", + "in": "query", + "required": False, + "schema": {"type": "string", "default": "system"}, + "description": "操作人标识(如邮箱)", + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "待打标签的 bag 名列表", + "example": ["a.bag", "b.bag"], + } + }, + } + } + }, + }, + "responses": { + "201": { + "description": "打标签完成", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tag_name": {"type": "string"}, + "creator": {"type": "string"}, + "total": {"type": "integer"}, + "succeeded": {"type": "integer"}, + "skipped": {"type": "integer"}, + "failed": {"type": "integer"}, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "status": { + "type": "string", + "enum": [ + "success", + "skipped", + "failed", + ], + }, + "message": {"type": "string"}, + }, + }, + }, + }, + } + } + }, + }, + "400": {"description": "参数缺失或空列表"}, + }, +}) +def batch_tag_bags_with_creator(tag_name: str): + """批量给 bag 打指定标签(含 creator 参数)""" + creator = request.args.get("creator", "system").strip() + body = request.get_json(silent=True) + if not body: + return jsonify({"error": "JSON body required"}), 400 + + bag_names = body.get("bag_names") + if not isinstance(bag_names, list) or len(bag_names) == 0: + return jsonify({ + "error": "bag_names(list) is required and cannot be empty" + }), 400 + + db = SessionLocal() + try: + stat = apply_reserved_tag_with_creator(db, tag_name, bag_names, creator) + return jsonify(stat), 201 + finally: + db.close() diff --git a/fst_data_pipeline/apps/root_db_api/src/api/topics.py b/fst_data_pipeline/apps/root_db_api/src/api/topics.py new file mode 100644 index 0000000..3ced526 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/topics.py @@ -0,0 +1,94 @@ +from flasgger import Swagger, swag_from +from flask import jsonify, Blueprint +from sqlalchemy.orm import Session + +from fst_data_pipeline.apps.root_db_api.src.core.service import ( + get_all_topics, + get_bags_by_topic_name, +) +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("topics", __name__, url_prefix="/topics") + +# Initialize Flasgger +swagger = Swagger() + + +# Get all topics +@bp.route("/all", methods=["GET"]) +@swag_from({ + "tags": ["主题管理"], + "responses": { + 200: { + "description": "获取所有主题列表", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "/camera/front/image_raw", + "description": "主题名称", + }, + "type": { + "type": "string", + "example": "sensor_msgs/Image", + "description": "消息类型", + }, + }, + }, + }, + } + }, +}) +def read_topics(): + """Fetch all topics""" # Get all topics + db: Session = SessionLocal() + topics = get_all_topics(db) + db.close() + return jsonify(topics) + + +# Get bags by topic name +@bp.route("/bags/", methods=["GET"]) +@swag_from({ + "openapi": "3.0.0", # 新增 + "tags": ["主题管理"], + "summary": "Fetch bags by topic name", + "parameters": [ + { + "name": "topic_name", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "The name of the topic", + } + ], + "responses": { + "200": { + "description": "Get bags related to the specified topic", + "content": { # 新增:用 content 包装 + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "array", + "items": {"type": "string", "example": "Bag Name"}, + } + }, + } + } + }, + }, + "404": {"description": "No related topic found"}, + }, +}) +def get_bags_by_topic(topic_name): + """Fetch bags by topic name""" # Get bags by topic name + db: Session = SessionLocal() + topic_name = "/" + topic_name + bags = get_bags_by_topic_name(db, topic_name=topic_name) + db.close() + return jsonify({"name": [b["name"] for b in bags]}) diff --git a/fst_data_pipeline/apps/root_db_api/src/api/versions.py b/fst_data_pipeline/apps/root_db_api/src/api/versions.py new file mode 100644 index 0000000..fd3809d --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/api/versions.py @@ -0,0 +1,618 @@ +from datetime import datetime +from io import BytesIO +import threading +from typing import Any, Dict, List +import uuid + +from flasgger import swag_from +from flask import Blueprint, Response, jsonify, request +from sqlalchemy.orm import Session +from openpyxl import Workbook + +from fst_data_pipeline.apps.root_db_api.src.core.service import ( + create_version, + list_versions, + update_version_status, + save_version_snapshot, + get_version_snapshot, + delete_version, + get_fst_stash, +) +from fst_data_pipeline.apps.root_db_api.src.core.feishu_bitable_sdk import ( + create_bitable_for_version, +) +from fst_data_pipeline.apps.root_db_api.src.core.feishu_api_constants import ( + ENABLE_FEISHU_SYNC, +) +from fst_data_pipeline.apps.root_db_api.src.core.models import VersionList +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +bp = Blueprint("versions", __name__, url_prefix="/versions") + +ACTIVATE_TASKS: Dict[str, Dict[str, Any]] = {} +ACTIVATE_TASKS_LOCK = threading.Lock() + + +def _activate_now_iso() -> str: + return datetime.utcnow().isoformat() + + +def _put_activate_task(task_id: str, task: Dict[str, Any]) -> None: + with ACTIVATE_TASKS_LOCK: + ACTIVATE_TASKS[task_id] = task + + +def _patch_activate_task(task_id: str, patch: Dict[str, Any]) -> None: + with ACTIVATE_TASKS_LOCK: + current = dict(ACTIVATE_TASKS.get(task_id) or {}) + current.update(patch) + current["updated_at"] = _activate_now_iso() + ACTIVATE_TASKS[task_id] = current + + +def _get_activate_task(task_id: str) -> Dict[str, Any]: + with ACTIVATE_TASKS_LOCK: + task = ACTIVATE_TASKS.get(task_id) or {} + return dict(task) + + +def _run_activate_task(task_id: str, version_id: int, content: Any) -> None: + db: Session = SessionLocal() + try: + _patch_activate_task(task_id, {"status": "running"}) + + version_obj = db.get(VersionList, version_id) + if version_obj is None: + _patch_activate_task(task_id, { + "status": "failed", + "error": "version not found", + }) + return + + # 当后端飞书同步开关关闭时,只生效版本,不触发外部飞书写入。 + if not ENABLE_FEISHU_SYNC: + version_obj.feishu_sync_status = "unsynced" + version_obj.status = "active" + db.commit() + + feishu_result = { + "skipped": True, + "reason": "feishu_sync_disabled", + } + _patch_activate_task(task_id, { + "status": "completed", + "version_id": version_obj.id, + "version": version_obj.version, + "result": { + "version_id": version_obj.id, + "version": version_obj.version, + "status": version_obj.status, + "feishu_sync_status": version_obj.feishu_sync_status, + "feishu_result": feishu_result, + }, + }) + return + + version_obj.feishu_sync_status = "syncing" + db.commit() + + feishu_result = create_bitable_for_version(version_obj.version, content) + + version_obj.feishu_sync_status = "synced" + version_obj.status = "active" + db.commit() + + _patch_activate_task(task_id, { + "status": "completed", + "version_id": version_obj.id, + "version": version_obj.version, + "result": { + "version_id": version_obj.id, + "version": version_obj.version, + "status": version_obj.status, + "feishu_sync_status": version_obj.feishu_sync_status, + "feishu_result": feishu_result, + }, + }) + except Exception as exc: + db.rollback() + version_obj = db.get(VersionList, version_id) + if version_obj is not None: + version_obj.feishu_sync_status = "failed" + db.commit() + _patch_activate_task(task_id, { + "status": "failed", + "error": str(exc), + }) + finally: + db.close() + + +@bp.route("", methods=["GET"]) +@swag_from({ + "tags": ["versions"], + "summary": "获取版本列表", + "responses": { + 200: { + "description": "版本列表", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "version": {"type": "string"}, + "type": {"type": "string"}, + "description": {"type": "string"}, + "release_date": {"type": "string"}, + "created_time": {"type": "string"}, + "update_time": {"type": "string"}, + "status": {"type": "string"}, + "feishu_sync_status": {"type": "string"}, + }, + }, + } + } + }, + } + }, +}) +def get_version_list(): + db: Session = SessionLocal() + try: + data = list_versions(db) + return jsonify(data) + finally: + db.close() + + +@bp.route("", methods=["POST"]) +@swag_from({ + "tags": ["versions"], + "summary": "新增版本", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["version"], + "properties": { + "version": {"type": "string", "example": "v1.0.0"}, + "type": {"type": "string", "example": "software"}, + "description": {"type": "string"}, + "release_date": { + "type": "string", + "format": "date-time", + }, + "status": {"type": "string", "example": "available"}, + "feishu_sync_status": { + "type": "string", + "example": "unsynced", + }, + }, + } + } + }, + }, + "responses": { + 200: {"description": "创建成功"}, + 400: {"description": "请求参数错误"}, + }, +}) +def create_version_item(): + payload = request.get_json(silent=True) or {} + version = payload.get("version") + if not version: + return jsonify({"error": "version 必填"}), 400 + + db: Session = SessionLocal() + try: + exists = ( + db.query(VersionList.id) + .filter(VersionList.version == version) + .first() + ) + if exists: + return jsonify({"message": "version already exists"}), 409 + + obj = create_version( + db, + version=version, + type=payload.get("type"), + description=payload.get("description"), + release_date=payload.get("release_date"), + status=payload.get("status"), + feishu_sync_status=payload.get("feishu_sync_status"), + ) + db.commit() + return jsonify({ + "id": obj.id, + "version": obj.version, + "type": obj.type, + "description": obj.description, + "release_date": obj.release_date, + "created_time": obj.created_time, + "update_time": obj.update_time, + "status": obj.status, + "feishu_sync_status": obj.feishu_sync_status, + }) + finally: + db.close() + + +@bp.route("/export", methods=["GET"]) +@swag_from({ + "tags": ["versions"], + "summary": "按版本导出 FST/Bag 快照", + "parameters": [ + { + "name": "version_id", + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": "版本号,对应 version_list.version;不传则导出版本列表", + } + ], + "responses": { + 200: { + "description": "Excel 文件", + "content": { + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary", + } + } + }, + }, + 400: {"description": "参数错误"}, + 404: {"description": "版本不存在"}, + }, +}) +def export_versions(): + version_token = request.args.get("version_id") + if not version_token: + db: Session = SessionLocal() + try: + data = list_versions(db) + finally: + db.close() + wb = Workbook() + ws = wb.active + ws.title = "Versions" + ws.append(["Version Name", "Create Time", "Update Time", "Status"]) + for item in data: + created = item.get("created_time") + updated = item.get("update_time") + created_str = created.isoformat() if isinstance(created, datetime) else str(created) if created is not None else "" + updated_str = updated.isoformat() if isinstance(updated, datetime) else str(updated) if updated is not None else "" + ws.append([ + item.get("version") or "", + created_str, + updated_str, + item.get("status") or "", + ]) + stream = BytesIO() + wb.save(stream) + stream.seek(0) + filename = f"versions_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx" + return Response( + stream.getvalue(), + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + db: Session = SessionLocal() + try: + version_obj = ( + db.query(VersionList) + .filter(VersionList.version == version_token) + .first() + ) + if version_obj is None and version_token.isdigit(): + version_obj = db.get(VersionList, int(version_token)) + if version_obj is None: + return jsonify({"error": "版本不存在"}), 404 + version_pk = version_obj.id + version_str = version_obj.version + snapshot = get_version_snapshot(db, version_pk) + if snapshot is None: + snapshot = save_version_snapshot(db, version_pk) + if snapshot is None: + return jsonify({"error": "版本不存在"}), 404 + finally: + db.close() + tree = snapshot or [] + wb = Workbook() + ws = wb.active + ws.title = "FST Tree Snapshot" + ws.append(["Version", "Node ID", "Label", "Level", "Parent Node ID"]) + + def _walk(nodes, parent_id=None): + for node in nodes: + node_id = node.get("id") + label = node.get("label") + level = node.get("level") + ws.append([ + version_str, + node_id or "", + label or "", + level if level is not None else "", + parent_id or "", + ]) + children = node.get("children") or [] + if children: + _walk(children, node_id or parent_id) + + _walk(tree) + stream = BytesIO() + wb.save(stream) + stream.seek(0) + filename = f"version_{version_str}_fst_snapshot_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx" + return Response( + stream.getvalue(), + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@bp.route("//snapshot", methods=["POST"]) +@swag_from({ + "tags": ["versions"], + "summary": "生成并保存指定版本的 FST/Bag 快照", + "responses": { + 200: {"description": "生成成功"}, + 404: {"description": "版本不存在"}, + }, +}) +def create_version_snapshot(version_id: int): + db: Session = SessionLocal() + try: + snapshot = save_version_snapshot(db, version_id) + if snapshot is None: + return jsonify({"error": "版本不存在"}), 404 + return jsonify({"version_id": version_id, "snapshot": snapshot}), 200 + finally: + db.close() + + +@bp.route("//status", methods=["PATCH"]) +@swag_from({ + "tags": ["versions"], + "summary": "更新版本状态与描述", + "parameters": [ + { + "name": "version_id", + "in": "path", + "required": True, + "type": "integer", + "description": "版本 ID", + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "example": "已生效", + }, + "description": { + "type": "string", + "example": "版本说明,可选", + }, + }, + } + } + }, + }, + "responses": { + 200: {"description": "更新成功"}, + 400: {"description": "请求参数错误"}, + 404: {"description": "未找到版本"}, + }, +}) +def update_version_status_api(version_id: int): + payload = request.get_json(silent=True) or {} + status = payload.get("status") + if not status: + return jsonify({"error": "status 必填"}), 400 + description = payload.get("description") + + db: Session = SessionLocal() + try: + obj = update_version_status( + db, + version_id=version_id, + status=status, + description=description, + ) + if obj is None: + return jsonify({"error": "version not found"}), 404 + db.commit() + return jsonify({ + "id": obj.id, + "version": obj.version, + "status": obj.status, + "description": obj.description, + }) + finally: + db.close() + + +@bp.route("/", methods=["DELETE"]) +@swag_from({ + "tags": ["versions"], + "summary": "删除指定版本", + "parameters": [ + { + "name": "version_id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + "description": "版本 ID(version_list.id)", + } + ], + "responses": { + 200: { + "description": "删除成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + }, + } + } + }, + }, + 404: { + "description": "未找到版本", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string"}, + }, + } + } + }, + }, + }, +}) +def delete_version_api(version_id: int): + db: Session = SessionLocal() + try: + ok = delete_version(db, version_id) + if not ok: + return jsonify({"error": "version not found"}), 404 + db.commit() + return jsonify({"success": True}), 200 + finally: + db.close() + + +@bp.route("/stash//activate", methods=["POST"]) +@bp.route("/fst/stash/activate/", methods=["POST"]) +@bp.route("//activate", methods=["POST"]) +@swag_from({ + "tags": ["versions"], + "summary": "生效版本并创建飞书多维表格(fst/stash 命名空间)", + "parameters": [ + { + "name": "version_id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + "description": "版本 ID(version_list.id)", + } + ], + "requestBody": { + "required": False, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "object", + "description": "可选,直接上传的 JSON;不传则读取 fst_stash.content", + }, + }, + } + } + }, + }, + "responses": { + 200: {"description": "生效并创建飞书多维表格成功"}, + 400: {"description": "参数错误"}, + 404: {"description": "版本或 JSON 不存在"}, + 500: {"description": "飞书调用或服务异常"}, + }, +}) +def activate_version_api(version_id: int): + payload = request.get_json(silent=True) or {} + input_content = payload.get("content") + + db: Session = SessionLocal() + try: + version_obj = db.get(VersionList, version_id) + if version_obj is None: + return jsonify({"error": "version not found"}), 404 + + content = input_content + if content is None: + stash = get_fst_stash(db, version=version_obj.version) + if stash is not None: + content = stash.content + + if content is None: + return jsonify({"error": "no json content found for this version"}), 404 + + task_id = uuid.uuid4().hex + now = _activate_now_iso() + _put_activate_task(task_id, { + "task_id": task_id, + "version_id": version_obj.id, + "version": version_obj.version, + "status": "queued", + "created_at": now, + "updated_at": now, + }) + + threading.Thread( + target=_run_activate_task, + args=(task_id, version_obj.id, content), + daemon=True, + ).start() + + return jsonify({ + "accepted": True, + "task_id": task_id, + "version_id": version_obj.id, + "version": version_obj.version, + "status": "queued", + }), 202 + finally: + db.close() + + +@bp.route("/stash//activate/result", methods=["GET"]) +@bp.route("//activate/result", methods=["GET"]) +def activate_version_result_api(version_id: int): + task_id = (request.args.get("task_id") or "").strip() + + db: Session = SessionLocal() + try: + version_obj = db.get(VersionList, version_id) + if version_obj is None: + return jsonify({"error": "version not found"}), 404 + finally: + db.close() + + if task_id: + task = _get_activate_task(task_id) + if not task: + return jsonify({"error": "task not found", "task_id": task_id}), 404 + return jsonify(task), 200 + + with ACTIVATE_TASKS_LOCK: + candidates = [ + dict(item) + for item in ACTIVATE_TASKS.values() + if isinstance(item, dict) and item.get("version_id") == version_id + ] + + if not candidates: + return jsonify({"error": "task not found for version", "version_id": version_id}), 404 + + candidates.sort(key=lambda x: str(x.get("updated_at") or ""), reverse=True) + return jsonify(candidates[0]), 200 diff --git a/fst_data_pipeline/apps/root_db_api/src/app.py b/fst_data_pipeline/apps/root_db_api/src/app.py new file mode 100644 index 0000000..2e1ce7e --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/app.py @@ -0,0 +1,34 @@ +# run.py +from flasgger import Swagger +from flask import Flask, render_template + +from fst_data_pipeline.apps.root_db_api.src.api import api_bp + +app = Flask(__name__, template_folder="../templates") + +# 注册蓝图 +app.register_blueprint(api_bp, url_prefix="/api") + +# Swagger 配置 +app.config["SWAGGER"] = { + "title": "ROOT DB API", + "version": "1.0.0", + "uiversion": 3, + "openapi": "3.0.0", +} +Swagger(app) + + +@app.route("/") +def hello_world(): + return "Hello World!" + + +@app.route("/data-browser") +def data_browser(): + """数据浏览器前端界面""" + return render_template("data_browser.html") + + +if __name__ == "__main__": + app.run(debug=False, host="0.0.0.0", port=5232) diff --git a/fst_data_pipeline/apps/root_db_api/src/core/__init__.py b/fst_data_pipeline/apps/root_db_api/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/root_db_api/src/core/feishu_api_constants.py b/fst_data_pipeline/apps/root_db_api/src/core/feishu_api_constants.py new file mode 100644 index 0000000..ff64637 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/feishu_api_constants.py @@ -0,0 +1,107 @@ +"""Shared Feishu Open API endpoints, common constants, and reusable helpers.""" + +import json +import os +import time +from typing import Any, Dict, Optional + +import requests + +WIKI_NODES_URL_TMPL = "https://open.feishu.cn/open-apis/wiki/v2/spaces/{space_id}/nodes" +WIKI_GET_NODE_URL = "https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node" +WIKI_NODE_URL_TMPL = "https://open.feishu.cn/open-apis/wiki/v2/spaces/{space_id}/nodes/{node_token}" + +DOCX_BLOCKS_URL_TMPL = "https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks" +DOCX_BLOCK_URL_TMPL = "https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}" +DOCX_BLOCK_CHILDREN_URL_TMPL = ( + "https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children" +) + +BITABLE_TABLES_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables" +BITABLE_TABLE_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}" +BITABLE_FIELDS_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" +BITABLE_FIELD_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}" +BITABLE_RECORDS_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" +BITABLE_RECORD_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" +BITABLE_RECORDS_BATCH_CREATE_URL_TMPL = ( + "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create" +) +BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL = ( + "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_update" +) +BITABLE_VIEWS_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/views" +BITABLE_VIEW_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/views/{view_id}" + +DOC_PAGE_SIZE = 50 +WIKI_NODE_PAGE_SIZE = 50 +MAX_PAGE_LOOP = 200 +RECORD_PAGE_SIZE = 500 +RECORD_BATCH_SIZE = 200 + +TEXT_FIELD_TYPE = 1 +LINK_FIELD_TYPE = 18 +PRIMARY_FIELD_TARGET_NAME = "Name" +PARENT_LINK_FIELD_NAME = "Parent Record Link" +TARGET_TEXT_FIELDS = ["Name", "Scene", "Level", "Describe"] + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + # Accept common true values and tolerate `tru` typo from env settings. + return raw.strip().lower() in {"1", "true", "tru", "yes", "on"} + + +# Backend Feishu sync master switch (default off). +ENABLE_FEISHU_SYNC = _env_bool("ENABLE_FEISHU_SYNC", default=False) + + +def build_auth_headers(tenant_access_token: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {tenant_access_token}", + "Content-Type": "application/json; charset=utf-8", + } + + +def raise_for_business_error(result: Dict[str, Any], action: str) -> None: + code = result.get("code", -1) + if code != 0: + msg = result.get("msg", "unknown error") + raise RuntimeError(f"{action} failed: code={code}, msg={msg}, raw={json.dumps(result, ensure_ascii=False)}") + + +def request_json_with_retry( + method: str, + url: str, + *, + headers: Dict[str, str], + action: str, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: int = 30, + max_attempts: int = 3, + backoff_seconds: int = 1, +) -> Dict[str, Any]: + last_error: Optional[str] = None + for attempt in range(1, max_attempts + 1): + try: + response = requests.request( + method=method, + url=url, + headers=headers, + params=params, + json=payload, + timeout=timeout, + ) + response.raise_for_status() + result = response.json() + raise_for_business_error(result, action) + return result + except Exception as exc: + last_error = str(exc) + + if attempt < max_attempts: + time.sleep(backoff_seconds * attempt) + + raise RuntimeError(f"{action} failed after retries: {last_error}") diff --git a/fst_data_pipeline/apps/root_db_api/src/core/feishu_bitable_sdk.py b/fst_data_pipeline/apps/root_db_api/src/core/feishu_bitable_sdk.py new file mode 100644 index 0000000..940d514 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/feishu_bitable_sdk.py @@ -0,0 +1,1768 @@ +import json +import threading +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Set + +import requests + +from fst_data_pipeline.apps.root_db_api.src.core.feishu_api_constants import ( + BITABLE_FIELDS_URL_TMPL, + BITABLE_FIELD_URL_TMPL, + BITABLE_RECORDS_URL_TMPL, + BITABLE_RECORD_URL_TMPL, + BITABLE_RECORDS_BATCH_CREATE_URL_TMPL, + BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL, + BITABLE_TABLES_URL_TMPL, + BITABLE_TABLE_URL_TMPL, + BITABLE_VIEWS_URL_TMPL, + BITABLE_VIEW_URL_TMPL, + LINK_FIELD_TYPE, + MAX_PAGE_LOOP, + PARENT_LINK_FIELD_NAME, + PRIMARY_FIELD_TARGET_NAME, + RECORD_BATCH_SIZE, + RECORD_PAGE_SIZE, + TEXT_FIELD_TYPE, + WIKI_NODES_URL_TMPL, + WIKI_NODE_PAGE_SIZE, + WIKI_NODE_URL_TMPL, + build_auth_headers as _auth_headers, + raise_for_business_error as _raise_for_business_error, +) +from fst_data_pipeline.apps.root_db_api.src.core.feishu_wiki_doc_sdk import ( + ensure_child_docs_for_first_level_names, +) + +# Hardcoded for temporary integration testing. +#KUNLUN +APP_ID = "cli_a9fb08d113781bc2" +APP_SECRET = "1WwpUXBRmUa5SWdb5xBbteemhFt5MD8I" +SPACE_ID = "7613238136885316569" +###DEV +#APP_ID = "cli_a924d867fe389bc9" +#APP_SECRET = "5h1FtUdbgW6qwWV2PzpnjmWNyQc4Ajp5" +#SPACE_ID = "7618480915672484820" + +PARENT_NODE_TOKEN = "" +FST_EDITOR_PARENT_DOC_TITLE = "Content generated automatically by bot" + +AUTH_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" +DEFAULT_JSON_FIELD_NAME = "JSON" +REQUIRED_TABLE_FIELDS = ["Name", "Scene", "Level", "Describe"] +# Keep only these columns after initialization; non-primary extras will be removed. +DEFAULT_FIELDS_TO_REMOVE = ["Name", "Scene", "Level", "Describe"] +PARENT_NODE_META_KEY = "__parent_name__" +FST_EDITOR_BITABLE_TITLE = "Fst_Editor" +# Prefer direct binding to the existing main bitable to avoid duplicate creation. +FST_EDITOR_NODE_TOKEN = "" +FST_EDITOR_APP_TOKEN = "" +BATCH_REQUEST_TIMEOUT = 20 +ENABLE_TABLE_FIELD_MAINTENANCE = True +ENABLE_WIKI_CHILD_DOC_SYNC = True +ENABLE_WIKI_CHILD_DOC_SYNC_ASYNC = True + + +def _run_wiki_child_doc_sync_safe( + tenant_access_token: str, + content: Any, + fst_editor_node_token: Optional[str], + app_token: str, +) -> Dict[str, Any]: + try: + return ensure_child_docs_for_first_level_names( + tenant_access_token=tenant_access_token, + content=content, + fst_editor_token=str(fst_editor_node_token) if fst_editor_node_token else None, + bitable_app_token=app_token, + space_id=SPACE_ID, + ) + except Exception as exc: + return { + "error": str(exc), + } + + +def _start_wiki_child_doc_sync_async( + tenant_access_token: str, + content: Any, + fst_editor_node_token: Optional[str], + app_token: str, +) -> None: + def _target() -> None: + _run_wiki_child_doc_sync_safe( + tenant_access_token=tenant_access_token, + content=content, + fst_editor_node_token=fst_editor_node_token, + app_token=app_token, + ) + + threading.Thread(target=_target, daemon=True).start() + + +def _is_fst_editor_title(title: str) -> bool: + normalized = title.strip().lower() + return normalized in { + FST_EDITOR_BITABLE_TITLE.strip().lower(), + "fst_editor", + } + + +def get_tenant_access_token(app_id: str = APP_ID, app_secret: str = APP_SECRET) -> str: + payload = {"app_id": app_id, "app_secret": app_secret} + headers = {"Content-Type": "application/json; charset=utf-8"} + + response = requests.post(AUTH_URL, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "get_tenant_access_token") + + token = result.get("tenant_access_token") + if not token: + raise RuntimeError(f"get_tenant_access_token failed: token missing, raw={json.dumps(result, ensure_ascii=False)}") + return token + + +def create_bitable_in_wiki( + tenant_access_token: str, + *, + space_id: str = SPACE_ID, + parent_node_token: Optional[str] = PARENT_NODE_TOKEN, + title: Optional[str] = None, +) -> Dict[str, Any]: + url = WIKI_NODES_URL_TMPL.format(space_id=space_id) + headers = { + "Authorization": f"Bearer {tenant_access_token}", + "Content-Type": "application/json; charset=utf-8", + } + + payload: Dict[str, Any] = { + "obj_type": "bitable", + "node_type": "origin", + } + if parent_node_token: + payload["parent_node_token"] = parent_node_token + if title: + payload["title"] = title + + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_in_wiki") + + data = result.get("data") or {} + node = data.get("node") or {} + if not node: + raise RuntimeError(f"create_bitable_in_wiki failed: node missing, raw={json.dumps(result, ensure_ascii=False)}") + return node + + +def _list_wiki_nodes_by_parent( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: Optional[str], +) -> List[Dict[str, Any]]: + url = WIKI_NODES_URL_TMPL.format(space_id=space_id) + headers = _auth_headers(tenant_access_token) + page_token = "" + items: List[Dict[str, Any]] = [] + seen_tokens: Set[str] = set() + loop_count = 0 + + while True: + loop_count += 1 + if loop_count > MAX_PAGE_LOOP: + raise RuntimeError("list_wiki_nodes_by_parent exceeded max pagination loops") + + params: Dict[str, Any] = {"page_size": WIKI_NODE_PAGE_SIZE} + if parent_node_token: + params["parent_node_token"] = parent_node_token + if page_token: + params["page_token"] = page_token + + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_wiki_nodes_by_parent") + + data = result.get("data") or {} + batch = data.get("items") or [] + for item in batch: + if isinstance(item, dict): + items.append(item) + + if not bool(data.get("has_more")): + break + + next_page_token = str(data.get("page_token") or "") + if not next_page_token: + break + if next_page_token == page_token: + raise RuntimeError("list_wiki_nodes_by_parent page_token did not advance") + if next_page_token in seen_tokens: + raise RuntimeError("list_wiki_nodes_by_parent detected repeated page_token") + + seen_tokens.add(next_page_token) + page_token = next_page_token + + return items + + +def _extract_wiki_node_title(node: Dict[str, Any]) -> str: + title = node.get("title") + if isinstance(title, str): + return title + + obj_create_info = node.get("obj_create_info") + if isinstance(obj_create_info, dict): + inner_title = obj_create_info.get("title") + if isinstance(inner_title, str): + return inner_title + inner_obj_title = obj_create_info.get("obj_title") + if isinstance(inner_obj_title, str): + return inner_obj_title + return "" + + +def _get_wiki_node_detail( + tenant_access_token: str, + *, + space_id: str, + node_token: str, +) -> Dict[str, Any]: + url = WIKI_NODE_URL_TMPL.format(space_id=space_id, node_token=node_token) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "get_wiki_node") + data = result.get("data") or {} + node = data.get("node") or {} + if not isinstance(node, dict): + return {} + return node + + +def _delete_wiki_node( + tenant_access_token: str, + *, + space_id: str, + node_token: str, +) -> None: + url = WIKI_NODE_URL_TMPL.format(space_id=space_id, node_token=node_token) + response = requests.delete(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "delete_wiki_node") + + +def _safe_list_wiki_nodes_by_parent( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: Optional[str], +) -> List[Dict[str, Any]]: + try: + return _list_wiki_nodes_by_parent( + tenant_access_token=tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + ) + except Exception: + return [] + + +def _resolve_node_obj_token( + tenant_access_token: str, + *, + space_id: str, + node: Dict[str, Any], +) -> Optional[str]: + obj_token = node.get("obj_token") + if isinstance(obj_token, str) and obj_token: + return obj_token + + node_token = node.get("node_token") + if not isinstance(node_token, str) or not node_token: + return None + + try: + detailed = _get_wiki_node_detail( + tenant_access_token=tenant_access_token, + space_id=space_id, + node_token=node_token, + ) + except Exception: + return None + + detail_obj_token = detailed.get("obj_token") + if isinstance(detail_obj_token, str) and detail_obj_token: + return detail_obj_token + return None + + +def _resolve_fst_editor_parent_node_token(tenant_access_token: str) -> Optional[str]: + if PARENT_NODE_TOKEN: + return PARENT_NODE_TOKEN + + root_nodes = _safe_list_wiki_nodes_by_parent( + tenant_access_token=tenant_access_token, + space_id=SPACE_ID, + parent_node_token=None, + ) + target_title = FST_EDITOR_PARENT_DOC_TITLE.strip().lower() + for node in root_nodes: + if not isinstance(node, dict): + continue + if str(node.get("obj_type") or "") not in {"doc", "docx"}: + continue + if _extract_wiki_node_title(node).strip().lower() != target_title: + continue + node_token = node.get("node_token") + if isinstance(node_token, str) and node_token: + return node_token + return None + + +def _get_or_create_fst_editor_bitable_node(tenant_access_token: str) -> Dict[str, Any]: + # 1) Prefer known node/app tokens and validate by space_id. + if FST_EDITOR_NODE_TOKEN and FST_EDITOR_APP_TOKEN: + try: + detail = _get_wiki_node_detail( + tenant_access_token=tenant_access_token, + space_id=SPACE_ID, + node_token=FST_EDITOR_NODE_TOKEN, + ) + detail_title = _extract_wiki_node_title(detail) + if detail_title and not _is_fst_editor_title(detail_title): + raise RuntimeError( + f"configured Fst_Editor node title mismatch: expect={FST_EDITOR_BITABLE_TITLE}, got={detail_title}" + ) + + return { + "title": detail_title or FST_EDITOR_BITABLE_TITLE, + "node_token": FST_EDITOR_NODE_TOKEN, + "obj_token": _resolve_node_obj_token( + tenant_access_token, + space_id=SPACE_ID, + node=detail, + ) + or FST_EDITOR_APP_TOKEN, + "obj_type": detail.get("obj_type") or "bitable", + } + except Exception: + # Fallback to discovery path if direct resolve fails. + pass + + # 2) Prefer creating/reusing Fst_Editor under configured target parent document. + target_parent_node_token = _resolve_fst_editor_parent_node_token(tenant_access_token) + if target_parent_node_token: + siblings = _safe_list_wiki_nodes_by_parent( + tenant_access_token=tenant_access_token, + space_id=SPACE_ID, + parent_node_token=target_parent_node_token, + ) + else: + # Backward-compatible fallback when no parent target is configured/resolved. + siblings = _safe_list_wiki_nodes_by_parent( + tenant_access_token=tenant_access_token, + space_id=SPACE_ID, + parent_node_token=None, + ) + + # Explicitly handle empty space response like: + # {"code":0, "data":{"has_more":false, "page_token":""}, "msg":"success"} + if not siblings: + return create_bitable_in_wiki( + tenant_access_token, + parent_node_token=target_parent_node_token, + title=FST_EDITOR_BITABLE_TITLE, + ) + + matched_nodes = [ + node + for node in siblings + if isinstance(node, dict) + and node.get("obj_type") == "bitable" + and _is_fst_editor_title(_extract_wiki_node_title(node)) + ] + + resolved_nodes: List[Dict[str, Any]] = [] + for node in matched_nodes: + resolved_obj_token = _resolve_node_obj_token( + tenant_access_token, + space_id=SPACE_ID, + node=node, + ) + if not resolved_obj_token: + continue + merged = dict(node) + merged["obj_token"] = resolved_obj_token + resolved_nodes.append(merged) + + if resolved_nodes: + # Keep one Fst_Editor bitable and best-effort clean up duplicated same-title bitables. + keep_node = resolved_nodes[0] + keep_node_token = keep_node.get("node_token") + for extra in resolved_nodes[1:]: + extra_node_token = extra.get("node_token") + if not isinstance(extra_node_token, str) or not extra_node_token: + continue + if isinstance(keep_node_token, str) and extra_node_token == keep_node_token: + continue + try: + _delete_wiki_node( + tenant_access_token=tenant_access_token, + space_id=SPACE_ID, + node_token=extra_node_token, + ) + except Exception: + # Best effort cleanup: do not block current sync if duplicate deletion fails. + pass + return keep_node + + # Avoid creating duplicated bitables when same-title node already exists but token resolution failed. + if matched_nodes: + raise RuntimeError( + "found existing Fst_Editor node but cannot resolve obj_token; stop creating duplicate bitable" + ) + + return create_bitable_in_wiki( + tenant_access_token, + parent_node_token=target_parent_node_token, + title=FST_EDITOR_BITABLE_TITLE, + ) + + +def _list_bitable_tables(tenant_access_token: str, app_token: str) -> List[Dict[str, Any]]: + url = BITABLE_TABLES_URL_TMPL.format(app_token=app_token) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_tables") + return ((result.get("data") or {}).get("items") or []) + + +def _create_bitable_table(tenant_access_token: str, app_token: str, table_name: str) -> Dict[str, Any]: + url = BITABLE_TABLES_URL_TMPL.format(app_token=app_token) + headers = _auth_headers(tenant_access_token) + payload_candidates = [ + {"table": {"name": table_name}}, + {"table_name": table_name}, + ] + last_error: Optional[str] = None + for payload in payload_candidates: + try: + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_table") + table = (result.get("data") or {}).get("table") or {} + if isinstance(table, dict) and table.get("table_id"): + return table + # Fallback: some tenants may return no table body; re-list by table name. + for listed in _list_bitable_tables(tenant_access_token, app_token): + if isinstance(listed, dict) and listed.get("name") == table_name and listed.get("table_id"): + return listed + last_error = f"table_id missing, raw={json.dumps(result, ensure_ascii=False)}" + except Exception as exc: + last_error = str(exc) + raise RuntimeError(f"create_bitable_table failed: {last_error}") + + +def _delete_bitable_table(tenant_access_token: str, app_token: str, table_id: str) -> None: + url = BITABLE_TABLE_URL_TMPL.format(app_token=app_token, table_id=table_id) + response = requests.delete(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "delete_bitable_table") + + +def _ensure_latest_version_table( + tenant_access_token: str, + app_token: str, + version: str, +) -> Dict[str, Any]: + target_table_name = str(version) + tables_before = _list_bitable_tables(tenant_access_token, app_token) + + target_table = next( + ( + table + for table in tables_before + if isinstance(table, dict) and table.get("name") == target_table_name and table.get("table_id") + ), + None, + ) + created = False + if not target_table: + target_table = _create_bitable_table(tenant_access_token, app_token, target_table_name) + created = True + + target_table_id = target_table.get("table_id") + if not isinstance(target_table_id, str) or not target_table_id: + raise RuntimeError( + f"ensure_latest_version_table failed: target table_id missing, raw={json.dumps(target_table, ensure_ascii=False)}" + ) + + deleted_table_ids: List[str] = [] + delete_failed: List[Dict[str, str]] = [] + tables_after_create = _list_bitable_tables(tenant_access_token, app_token) + for table in tables_after_create: + if not isinstance(table, dict): + continue + table_id = table.get("table_id") + table_name = table.get("name") + if not isinstance(table_id, str) or not table_id: + continue + if table_id == target_table_id: + continue + if not isinstance(table_name, str): + continue + try: + _delete_bitable_table(tenant_access_token, app_token, table_id) + deleted_table_ids.append(table_id) + except Exception as exc: + delete_failed.append( + { + "table_id": table_id, + "table_name": table_name, + "error": str(exc), + } + ) + + return { + "table": target_table, + "created": created, + "deleted_table_ids": deleted_table_ids, + "delete_failed": delete_failed, + "target_table_name": target_table_name, + } + + +def _get_first_table(tenant_access_token: str, app_token: str) -> Dict[str, Any]: + items = _list_bitable_tables(tenant_access_token, app_token) + if not items: + raise RuntimeError("list_bitable_tables failed: empty table list") + return items[0] + + +def _get_or_create_text_field_name(tenant_access_token: str, app_token: str, table_id: str) -> str: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + items = _list_table_fields(tenant_access_token, app_token, table_id) + for item in items: + if item.get("type") == TEXT_FIELD_TYPE and item.get("field_name"): + return str(item["field_name"]) + + # If no text field exists, create one dedicated to JSON payload. + create_payload = { + "field_name": DEFAULT_JSON_FIELD_NAME, + "type": TEXT_FIELD_TYPE, + } + create_resp = requests.post(url, json=create_payload, headers=_auth_headers(tenant_access_token), timeout=30) + create_resp.raise_for_status() + create_result = create_resp.json() + _raise_for_business_error(create_result, "create_bitable_text_field") + + field = (create_result.get("data") or {}).get("field") or {} + field_name = field.get("field_name") + if not field_name: + raise RuntimeError( + f"create_bitable_text_field failed: field_name missing, raw={json.dumps(create_result, ensure_ascii=False)}" + ) + return field_name + + +def _list_table_field_names(tenant_access_token: str, app_token: str, table_id: str) -> List[str]: + items = _list_table_fields(tenant_access_token, app_token, table_id) + names: List[str] = [] + for item in items: + name = item.get("field_name") + if isinstance(name, str) and name: + names.append(name) + return names + + +def _list_table_fields(tenant_access_token: str, app_token: str, table_id: str) -> List[Dict[str, Any]]: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_fields") + return ((result.get("data") or {}).get("items") or []) + + +def _create_text_field(tenant_access_token: str, app_token: str, table_id: str, field_name: str) -> None: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "field_name": field_name, + "type": TEXT_FIELD_TYPE, + } + response = requests.post(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_text_field") + + +def _create_link_field(tenant_access_token: str, app_token: str, table_id: str, field_name: str) -> Dict[str, Any]: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "field_name": field_name, + "type": LINK_FIELD_TYPE, + "property": { + "multiple": True, + "table_id": table_id, + }, + } + response = requests.post(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_link_field") + return ((result.get("data") or {}).get("field") or {}) + + +def _ensure_parent_link_field( + tenant_access_token: str, + app_token: str, + table_id: str, + *, + field_name: str = PARENT_LINK_FIELD_NAME, +) -> Dict[str, Any]: + fields = _list_table_fields(tenant_access_token, app_token, table_id) + for field in fields: + if field.get("field_name") == field_name: + if field.get("type") != LINK_FIELD_TYPE: + raise RuntimeError( + f"field '{field_name}' exists but is not linked-record type: got type={field.get('type')}" + ) + return field + + created = _create_link_field(tenant_access_token, app_token, table_id, field_name) + if not created: + # Fall back to list when create API returns empty body in some tenants. + refreshed = _list_table_fields(tenant_access_token, app_token, table_id) + for field in refreshed: + if field.get("field_name") == field_name and field.get("type") == LINK_FIELD_TYPE: + return field + return created + + +def _list_table_views(tenant_access_token: str, app_token: str, table_id: str) -> List[Dict[str, Any]]: + url = BITABLE_VIEWS_URL_TMPL.format(app_token=app_token, table_id=table_id) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_views") + return ((result.get("data") or {}).get("items") or []) + + +def _set_hierarchy_view_field( + tenant_access_token: str, + app_token: str, + table_id: str, + view_id: str, + link_field_id: str, +) -> None: + url = BITABLE_VIEW_URL_TMPL.format(app_token=app_token, table_id=table_id, view_id=view_id) + payload = { + "property": { + "hierarchy_config": { + "field_id": link_field_id, + } + } + } + response = requests.patch(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "patch_bitable_view_hierarchy") + + +def _build_ordered_record_ids_after_reuse( + rows: List[Dict[str, Any]], + reusable_record_ids: List[str], + updated_record_ids: List[str], + created_record_ids: List[str], +) -> List[str]: + pair_count = min(len(reusable_record_ids), len(rows)) + updated_id_set = set(updated_record_ids) + created_iter = iter(created_record_ids) + ordered_ids: List[str] = [] + + for rid in reusable_record_ids[:pair_count]: + if rid in updated_id_set: + ordered_ids.append(rid) + else: + ordered_ids.append(next(created_iter)) + + for _ in rows[pair_count:]: + ordered_ids.append(next(created_iter)) + + if len(ordered_ids) != len(rows): + raise RuntimeError( + "reconstruct_record_ids_failed: ordered ids count does not match row count, " + f"rows={len(rows)}, ordered={len(ordered_ids)}" + ) + + return ordered_ids + + +def _link_sub_records_by_parent_name( + tenant_access_token: str, + app_token: str, + table_id: str, + rows: List[Dict[str, Any]], + record_ids_in_row_order: List[str], + link_field_name: str, +) -> Dict[str, Any]: + if len(rows) != len(record_ids_in_row_order): + return { + "linked_count": 0, + "skipped_count": len(rows), + "failed_count": 0, + "error": "rows and record_ids length mismatch", + } + + name_to_record_id: Dict[str, str] = {} + for row, record_id in zip(rows, record_ids_in_row_order): + name = row.get("Name") + if name is None: + name = row.get("Text") + name_text = _stringify_cell_value(name).strip() + if name_text and name_text not in name_to_record_id: + name_to_record_id[name_text] = record_id + + updates: List[Dict[str, Any]] = [] + skipped_count = 0 + for row, record_id in zip(rows, record_ids_in_row_order): + parent_name = _stringify_cell_value(row.get(PARENT_NODE_META_KEY)).strip() + if not parent_name: + parent_name = _stringify_cell_value(row.get("Parent Node")).strip() + if not parent_name: + skipped_count += 1 + continue + parent_record_id = name_to_record_id.get(parent_name) + if not parent_record_id or parent_record_id == record_id: + skipped_count += 1 + continue + updates.append( + { + "record_id": record_id, + "fields": { + link_field_name: [parent_record_id], + }, + } + ) + + if not updates: + return { + "linked_count": 0, + "skipped_count": skipped_count, + "failed_count": 0, + } + + headers = _auth_headers(tenant_access_token) + failed_count = 0 + linked_count = 0 + for start in range(0, len(updates), RECORD_BATCH_SIZE): + chunk = updates[start:start + RECORD_BATCH_SIZE] + url = BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = {"records": chunk} + try: + response = requests.post(url, json=payload, headers=headers, timeout=BATCH_REQUEST_TIMEOUT) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "batch_update_sub_record_links") + returned = ((result.get("data") or {}).get("records") or []) + linked_count += len(returned) + failed_count += max(0, len(chunk) - len(returned)) + except Exception: + failed_count += len(chunk) + + return { + "linked_count": linked_count, + "skipped_count": skipped_count, + "failed_count": failed_count, + } + + +def _delete_field(tenant_access_token: str, app_token: str, table_id: str, field_id: str) -> None: + url = BITABLE_FIELD_URL_TMPL.format(app_token=app_token, table_id=table_id, field_id=field_id) + response = requests.delete(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "delete_bitable_field") + + +def _rename_field( + tenant_access_token: str, + app_token: str, + table_id: str, + field_id: str, + new_name: str, + field_meta: Optional[Dict[str, Any]] = None, +) -> None: + url = BITABLE_FIELD_URL_TMPL.format(app_token=app_token, table_id=table_id, field_id=field_id) + payload: Dict[str, Any] = {"field_name": new_name} + # Feishu edit field endpoint is more reliable when type/ui_type are preserved. + if field_meta: + field_type = field_meta.get("type") + ui_type = field_meta.get("ui_type") + if isinstance(field_type, int): + payload["type"] = field_type + if isinstance(ui_type, int): + payload["ui_type"] = ui_type + + # Feishu versions differ on PUT/PATCH support; try both and accept first business success. + headers = _auth_headers(tenant_access_token) + last_error: Optional[str] = None + for method in (requests.put, requests.patch): + try: + response = method(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + if result.get("code", -1) == 0: + return + last_error = f"code={result.get('code')}, msg={result.get('msg', 'unknown error')}" + except Exception as exc: + last_error = str(exc) + raise RuntimeError(f"rename_bitable_field failed: {last_error}") + + +def _normalize_json_text(content: Any) -> str: + if isinstance(content, str): + try: + parsed = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"content is not valid JSON string: {exc}") from exc + return json.dumps(parsed, ensure_ascii=False) + return json.dumps(content, ensure_ascii=False) + + +def _stringify_cell_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def _build_records_from_nodes_payload(parsed_content: Any) -> List[Dict[str, Any]]: + if not isinstance(parsed_content, dict): + return [] + nodes = parsed_content.get("nodes") + if not isinstance(nodes, list) or not nodes: + return [] + + # Tree-shaped payload: nodes with nested children. + if any(isinstance(item, dict) and "children" in item for item in nodes): + records: List[Dict[str, Any]] = [] + + def _walk_tree(tree_nodes: List[Dict[str, Any]], parent_name: str) -> None: + for item in tree_nodes: + if not isinstance(item, dict): + continue + + name = item.get("name") + if name is None: + name = item.get("id") + + description = item.get("Description") + if description is None: + description = item.get("description") + if description is None: + description = item.get("label") + + node_level = item.get("treeLevel") + + record_fields = { + "Name": _stringify_cell_value(name), + "Describe": _stringify_cell_value(description), + "Level": _stringify_cell_value(node_level), + PARENT_NODE_META_KEY: _stringify_cell_value(parent_name), + "Scene": _stringify_cell_value(item.get("scene")), + } + records.append(record_fields) + + children = item.get("children") + if isinstance(children, list) and children: + _walk_tree(children, _stringify_cell_value(name)) + + _walk_tree(nodes, "") + return records + + records: List[Dict[str, Any]] = [] + for item in nodes: + if not isinstance(item, dict): + continue + node_id = item.get("id") + if node_id is None: + continue + + description = item.get("name") + if description is None: + description = item.get("label") + if description is None: + description = item.get("Description") + if description is None: + description = item.get("description") + + level = item.get("treeLevel") + + record_fields = { + "Name": _stringify_cell_value(node_id), + "Describe": _stringify_cell_value(description), + "Level": _stringify_cell_value(level), + PARENT_NODE_META_KEY: _stringify_cell_value(item.get("parentId")), + "Scene": _stringify_cell_value(item.get("scene")), + } + records.append(record_fields) + + return records + + +def _extract_native_records(content: Any, table_field_names: Set[str]) -> List[Dict[str, Any]]: + parsed_content = content + if isinstance(content, str): + try: + parsed_content = json.loads(content) + except json.JSONDecodeError: + return [] + + # 1) Supports payload like: {"fields": {...}} + if isinstance(parsed_content, dict): + fields = parsed_content.get("fields") + if isinstance(fields, dict) and fields: + return [fields] + + # 2) Supports payload where top-level keys are field names. + if isinstance(parsed_content, dict): + mapped_fields = { + key: value + for key, value in parsed_content.items() + if isinstance(key, str) and key in table_field_names + } + if mapped_fields: + return [mapped_fields] + + # 3) Supports FST stash format: {"nodes": [...]} and writes one row per node. + rows_from_nodes = _build_records_from_nodes_payload(parsed_content) + if rows_from_nodes: + return rows_from_nodes + + return [] + + +def _strip_internal_meta_fields(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + cleaned: List[Dict[str, Any]] = [] + for row in rows: + cleaned.append({k: v for k, v in row.items() if not (isinstance(k, str) and k.startswith("__"))}) + return cleaned + + +def _ensure_field_names_exist( + tenant_access_token: str, + app_token: str, + table_id: str, + fields: Dict[str, Any], +) -> List[str]: + existing_names = set(_list_table_field_names(tenant_access_token, app_token, table_id)) + created_names: List[str] = [] + for name in fields.keys(): + if not isinstance(name, str) or not name: + continue + if name in existing_names: + continue + _create_text_field(tenant_access_token, app_token, table_id, name) + existing_names.add(name) + created_names.append(name) + return created_names + + +def _ensure_required_table_fields( + tenant_access_token: str, + app_token: str, + table_id: str, + field_names: Optional[List[str]] = None, +) -> List[str]: + """Ensure required business columns exist in table.""" + names = field_names or REQUIRED_TABLE_FIELDS + required_fields = {name: "" for name in names} + return _ensure_field_names_exist( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=required_fields, + ) + + +def _apply_name_alias_to_records(records: List[Dict[str, Any]], table_field_names: Set[str]) -> List[Dict[str, Any]]: + """If Name column is unavailable but Text exists, map Name value to Text.""" + if "Name" in table_field_names or "Text" not in table_field_names: + return records + + mapped_records: List[Dict[str, Any]] = [] + for row in records: + mapped = dict(row) + if "Name" in mapped and "Text" not in mapped: + mapped["Text"] = mapped.pop("Name") + mapped_records.append(mapped) + return mapped_records + + +def _rename_primary_field_to_target( + tenant_access_token: str, + app_token: str, + table_id: str, + target_name: str = PRIMARY_FIELD_TARGET_NAME, +) -> Dict[str, Any]: + """Rename primary field to target name when possible.""" + fields = _list_table_fields(tenant_access_token, app_token, table_id) + name_to_field = { + str(field.get("field_name")): field + for field in fields + if isinstance(field.get("field_name"), str) + } + primary_field = next((field for field in fields if field.get("is_primary")), None) + if not primary_field: + return {"renamed": False, "reason": "primary_field_not_found"} + + primary_name = primary_field.get("field_name") + primary_field_id = primary_field.get("field_id") + if primary_name == target_name: + return {"renamed": False, "reason": "already_target_name"} + + # If target already exists as non-primary, remove it first to avoid name conflict. + existing_target = name_to_field.get(target_name) + removed_conflict = False + if existing_target and not existing_target.get("is_primary"): + existing_target_id = existing_target.get("field_id") + if isinstance(existing_target_id, str) and existing_target_id: + try: + _delete_field(tenant_access_token, app_token, table_id, existing_target_id) + removed_conflict = True + except Exception as exc: + return { + "renamed": False, + "reason": "delete_conflict_field_failed", + "removed_conflict": False, + "error": str(exc), + } + + if not isinstance(primary_field_id, str) or not primary_field_id: + return {"renamed": False, "reason": "primary_field_id_missing", "removed_conflict": removed_conflict} + + try: + _rename_field( + tenant_access_token, + app_token, + table_id, + primary_field_id, + target_name, + primary_field, + ) + except Exception as exc: + return { + "renamed": False, + "reason": "rename_request_failed", + "removed_conflict": removed_conflict, + "error": str(exc), + } + + return { + "renamed": True, + "from": primary_name, + "to": target_name, + "removed_conflict": removed_conflict, + } + + +def _remove_default_table_fields( + tenant_access_token: str, + app_token: str, + table_id: str, +) -> Dict[str, Any]: + """Best-effort cleanup of non-target columns in newly created table.""" + removed: List[str] = [] + skipped_primary: List[str] = [] + failed: List[str] = [] + keep_field_names = set(DEFAULT_FIELDS_TO_REMOVE) + + for field in _list_table_fields(tenant_access_token, app_token, table_id): + name = field.get("field_name") + field_id = field.get("field_id") + is_primary = bool(field.get("is_primary")) + if not isinstance(name, str): + continue + + # Keep required business columns and only clean up extra defaults. + if name in keep_field_names: + continue + + if is_primary: + skipped_primary.append(name) + continue + + if not isinstance(field_id, str) or not field_id: + failed.append(name) + continue + + try: + _delete_field(tenant_access_token, app_token, table_id, field_id) + removed.append(name) + except Exception: + failed.append(name) + + return { + "removed": removed, + "skipped_primary": skipped_primary, + "failed": failed, + } + + +def _create_record( + tenant_access_token: str, + app_token: str, + table_id: str, + fields: Dict[str, Any], +) -> str: + url = BITABLE_RECORDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = {"fields": fields} + response = requests.post(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_record") + + record_id = (((result.get("data") or {}).get("record") or {}).get("record_id")) + if not record_id: + raise RuntimeError(f"create_bitable_record failed: record_id missing, raw={json.dumps(result, ensure_ascii=False)}") + return record_id + + +def _create_records_batch( + tenant_access_token: str, + app_token: str, + table_id: str, + rows: List[Dict[str, Any]], +) -> List[str]: + if not rows: + return [] + + all_record_ids: List[str] = [] + headers = _auth_headers(tenant_access_token) + + for start in range(0, len(rows), RECORD_BATCH_SIZE): + chunk = rows[start:start + RECORD_BATCH_SIZE] + url = BITABLE_RECORDS_BATCH_CREATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "records": [{"fields": row} for row in chunk], + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=BATCH_REQUEST_TIMEOUT) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "batch_create_bitable_records") + records = ((result.get("data") or {}).get("records") or []) + chunk_ids = [r.get("record_id") for r in records if isinstance(r, dict) and r.get("record_id")] + if len(chunk_ids) != len(chunk): + raise RuntimeError( + "batch_create_bitable_records failed: record count mismatch, " + f"expect={len(chunk)}, got_ids={len(chunk_ids)}" + ) + all_record_ids.extend(chunk_ids) + except Exception: + # Fallback to single-create if batch endpoint/response is unavailable. + for row in chunk: + all_record_ids.append( + _create_record( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=row, + ) + ) + + return all_record_ids + + +def _list_all_record_ids(tenant_access_token: str, app_token: str, table_id: str) -> List[str]: + record_ids: List[str] = [] + for item in _list_records_with_fields(tenant_access_token, app_token, table_id): + record_id = item.get("record_id") + if isinstance(record_id, str) and record_id: + record_ids.append(record_id) + return record_ids + + +def _delete_record(tenant_access_token: str, app_token: str, table_id: str, record_id: str) -> None: + url = BITABLE_RECORD_URL_TMPL.format(app_token=app_token, table_id=table_id, record_id=record_id) + response = requests.delete(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "delete_bitable_record") + + +def _clear_existing_records(tenant_access_token: str, app_token: str, table_id: str) -> Dict[str, Any]: + existing_ids = _list_all_record_ids(tenant_access_token, app_token, table_id) + if not existing_ids: + return {"before_count": 0, "deleted_count": 0, "failed_record_ids": []} + + failed_record_ids = _delete_records_by_ids( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + record_ids=existing_ids, + ) + + return { + "before_count": len(existing_ids), + "deleted_count": len(existing_ids) - len(failed_record_ids), + "failed_record_ids": failed_record_ids, + } + + +def _delete_first_n_records( + tenant_access_token: str, + app_token: str, + table_id: str, + n: int, +) -> Dict[str, Any]: + if n <= 0: + return {"target_count": 0, "deleted_count": 0, "failed_record_ids": []} + + record_ids = _list_all_record_ids(tenant_access_token, app_token, table_id) + target_ids = record_ids[:n] + failed_record_ids = _delete_records_by_ids( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + record_ids=target_ids, + ) + + return { + "target_count": len(target_ids), + "deleted_count": len(target_ids) - len(failed_record_ids), + "failed_record_ids": failed_record_ids, + } + + +def _list_records_with_fields(tenant_access_token: str, app_token: str, table_id: str) -> List[Dict[str, Any]]: + url = BITABLE_RECORDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + headers = _auth_headers(tenant_access_token) + page_token = "" + records: List[Dict[str, Any]] = [] + seen_tokens: Set[str] = set() + loop_count = 0 + + while True: + loop_count += 1 + if loop_count > MAX_PAGE_LOOP: + raise RuntimeError("list_bitable_records(fields) exceeded max pagination loops") + + params = {"page_size": RECORD_PAGE_SIZE} + if page_token: + params["page_token"] = page_token + + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_records") + + data = result.get("data") or {} + items = data.get("items") or [] + for item in items: + if isinstance(item, dict): + records.append(item) + + if not bool(data.get("has_more")): + break + + next_page_token = str(data.get("page_token") or "") + if not next_page_token: + break + if next_page_token == page_token: + raise RuntimeError("list_bitable_records(fields) page_token did not advance") + if next_page_token in seen_tokens: + raise RuntimeError("list_bitable_records(fields) detected repeated page_token") + + seen_tokens.add(next_page_token) + page_token = next_page_token + + return records + + +def _delete_records_by_ids( + tenant_access_token: str, + app_token: str, + table_id: str, + record_ids: List[str], +) -> List[str]: + failed_record_ids: List[str] = [] + for record_id in record_ids: + try: + _delete_record(tenant_access_token, app_token, table_id, record_id) + except Exception: + failed_record_ids.append(record_id) + return failed_record_ids + + +def _is_empty_business_record(fields: Dict[str, Any]) -> bool: + keys = ["Name", "Text", "Scene", "Level", "Describe"] + for key in keys: + value = fields.get(key) + if value is None: + continue + if isinstance(value, str) and value.strip() == "": + continue + if isinstance(value, (list, dict)) and len(value) == 0: + continue + return False + return True + + +def _find_reusable_empty_record_ids( + tenant_access_token: str, + app_token: str, + table_id: str, + limit: int, +) -> List[str]: + if limit <= 0: + return [] + reusable_ids: List[str] = [] + for item in _list_records_with_fields(tenant_access_token, app_token, table_id): + record_id = item.get("record_id") + fields = item.get("fields") or {} + if not isinstance(record_id, str) or not record_id: + continue + if not isinstance(fields, dict): + continue + if not _is_empty_business_record(fields): + # Only reuse leading empty records to preserve existing real data order. + break + reusable_ids.append(record_id) + if len(reusable_ids) >= limit: + break + return reusable_ids + + +def _update_record( + tenant_access_token: str, + app_token: str, + table_id: str, + record_id: str, + fields: Dict[str, Any], +) -> None: + url = BITABLE_RECORD_URL_TMPL.format(app_token=app_token, table_id=table_id, record_id=record_id) + payload = {"fields": fields} + headers = _auth_headers(tenant_access_token) + + last_error: Optional[str] = None + for method in (requests.put, requests.patch): + try: + response = method(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + if result.get("code", -1) == 0: + return + last_error = f"code={result.get('code')}, msg={result.get('msg', 'unknown error')}" + except Exception as exc: + last_error = str(exc) + raise RuntimeError(f"update_bitable_record failed: {last_error}") + + +def _update_records_reuse_first( + tenant_access_token: str, + app_token: str, + table_id: str, + record_ids: List[str], + rows: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Update reusable empty records first; return rows that still need create.""" + pair_count = min(len(record_ids), len(rows)) + if pair_count <= 0: + return { + "updated_record_ids": [], + "update_failed_record_ids": [], + "remaining_rows": rows, + } + + headers = _auth_headers(tenant_access_token) + updated_record_ids: List[str] = [] + update_failed_record_ids: List[str] = [] + remaining_rows: List[Dict[str, Any]] = [] + + pairs = list(zip(record_ids[:pair_count], rows[:pair_count])) + for start in range(0, len(pairs), RECORD_BATCH_SIZE): + chunk = pairs[start:start + RECORD_BATCH_SIZE] + payload = { + "records": [ + { + "record_id": rid, + "fields": row, + } + for rid, row in chunk + ] + } + url = BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + + try: + response = requests.post(url, json=payload, headers=headers, timeout=BATCH_REQUEST_TIMEOUT) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "batch_update_bitable_records") + returned = ((result.get("data") or {}).get("records") or []) + returned_ids = { + item.get("record_id") + for item in returned + if isinstance(item, dict) and isinstance(item.get("record_id"), str) + } + for rid, row in chunk: + if rid in returned_ids: + updated_record_ids.append(rid) + else: + update_failed_record_ids.append(rid) + remaining_rows.append(row) + except Exception: + # Skip single-update fallback; failed rows will be appended via create. + for rid, row in chunk: + update_failed_record_ids.append(rid) + remaining_rows.append(row) + + # Rows beyond reusable record count should be created. + remaining_rows.extend(rows[pair_count:]) + return { + "updated_record_ids": updated_record_ids, + "update_failed_record_ids": update_failed_record_ids, + "remaining_rows": remaining_rows, + } + + +def create_record_with_fields( + tenant_access_token: str, + app_token: str, + table_id: str, + fields: Dict[str, Any], +) -> str: + """Create one bitable record using native Feishu `fields` payload format.""" + if not isinstance(fields, dict) or not fields: + raise ValueError("fields must be a non-empty dict") + return _create_record( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=fields, + ) + + +def create_bitable_for_version(version: str, content: Any) -> Dict[str, Any]: + """Write version data into fixed bitable `Fst_Editor` and keep only latest version table.""" + tenant_access_token = get_tenant_access_token() + + node = _get_or_create_fst_editor_bitable_node(tenant_access_token) + + app_token = node.get("obj_token") + if not app_token: + raise RuntimeError(f"create_bitable_in_wiki failed: obj_token missing, raw={json.dumps(node, ensure_ascii=False)}") + + fst_editor_node_token = node.get("node_token") + if ENABLE_WIKI_CHILD_DOC_SYNC: + if ENABLE_WIKI_CHILD_DOC_SYNC_ASYNC: + _start_wiki_child_doc_sync_async( + tenant_access_token=tenant_access_token, + content=content, + fst_editor_node_token=fst_editor_node_token, + app_token=app_token, + ) + wiki_child_docs_result = { + "queued": True, + "mode": "async", + "fst_editor_node_token": fst_editor_node_token, + } + else: + wiki_child_docs_result = _run_wiki_child_doc_sync_safe( + tenant_access_token=tenant_access_token, + content=content, + fst_editor_node_token=fst_editor_node_token, + app_token=app_token, + ) + else: + wiki_child_docs_result = { + "skipped": True, + "reason": "wiki_child_doc_sync_disabled", + } + + table_lifecycle_result = _ensure_latest_version_table( + tenant_access_token=tenant_access_token, + app_token=app_token, + version=version, + ) + table = table_lifecycle_result["table"] + table_id = table.get("table_id") + if not table_id: + raise RuntimeError(f"list_bitable_tables failed: table_id missing, raw={json.dumps(table, ensure_ascii=False)}") + + clear_records_result = { + "before_count": 0, + "deleted_count": 0, + "failed_record_ids": [], + "skipped": True, + "reason": "feature_removed_keep_existing_records", + } + + if ENABLE_TABLE_FIELD_MAINTENANCE: + table_field_name_before = _list_table_field_names(tenant_access_token, app_token, table_id) + try: + primary_field_rename = _rename_primary_field_to_target( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + except Exception as exc: + primary_field_rename = { + "renamed": False, + "reason": "rename_step_failed", + "error": str(exc), + } + + required_field_names = list(REQUIRED_TABLE_FIELDS) + if not primary_field_rename.get("renamed") and "Text" in table_field_name_before: + required_field_names = ["Text" if name == "Name" else name for name in required_field_names] + + try: + required_created_field_names = _ensure_required_table_fields( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + field_names=required_field_names, + ) + except Exception as exc: + required_created_field_names = [] + primary_field_rename["ensure_required_fields_error"] = str(exc) + + try: + default_field_cleanup = _remove_default_table_fields( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + except Exception as exc: + default_field_cleanup = { + "removed": [], + "skipped_primary": [], + "failed": [], + "error": str(exc), + } + else: + primary_field_rename = {"renamed": False, "reason": "maintenance_disabled"} + required_created_field_names = [] + default_field_cleanup = { + "removed": [], + "skipped_primary": [], + "failed": [], + "skipped": True, + } + + table_field_name_list = _list_table_field_names(tenant_access_token, app_token, table_id) + table_field_names = set(table_field_name_list) + native_records = _extract_native_records(content, table_field_names) + if native_records: + link_source_records = native_records + write_records = _strip_internal_meta_fields(native_records) + write_records = _apply_name_alias_to_records(write_records, table_field_names) + combined_field_names: Dict[str, Any] = {} + for row in write_records: + for key in row.keys(): + combined_field_names[key] = "" + auto_created_field_names = _ensure_field_names_exist( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=combined_field_names, + ) + matched_field_names = list(combined_field_names.keys()) + reusable_empty_ids = _find_reusable_empty_record_ids( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + limit=len(write_records), + ) + + update_result = _update_records_reuse_first( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + record_ids=reusable_empty_ids, + rows=write_records, + ) + updated_record_ids = update_result["updated_record_ids"] + update_failed_record_ids = update_result["update_failed_record_ids"] + remaining_rows = update_result["remaining_rows"] + + created_record_ids = _create_records_batch( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + rows=remaining_rows, + ) + ordered_record_ids = _build_ordered_record_ids_after_reuse( + rows=write_records, + reusable_record_ids=reusable_empty_ids, + updated_record_ids=updated_record_ids, + created_record_ids=created_record_ids, + ) + record_ids = updated_record_ids + created_record_ids + + try: + parent_link_field = _ensure_parent_link_field( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + parent_link_field_id = parent_link_field.get("field_id") + parent_link_field_name = _stringify_cell_value(parent_link_field.get("field_name") or PARENT_LINK_FIELD_NAME) + + parent_child_link_result = _link_sub_records_by_parent_name( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + rows=link_source_records, + record_ids_in_row_order=ordered_record_ids, + link_field_name=parent_link_field_name, + ) + + hierarchy_view_result: Dict[str, Any] = {"enabled": False, "reason": "view_not_updated"} + if isinstance(parent_link_field_id, str) and parent_link_field_id: + try: + views = _list_table_views( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + first_view = views[0] if views else {} + view_id = first_view.get("view_id") if isinstance(first_view, dict) else None + if isinstance(view_id, str) and view_id: + _set_hierarchy_view_field( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + view_id=view_id, + link_field_id=parent_link_field_id, + ) + hierarchy_view_result = { + "enabled": True, + "view_id": view_id, + "link_field_id": parent_link_field_id, + } + else: + hierarchy_view_result = { + "enabled": False, + "reason": "view_id_not_found", + } + except Exception as exc: + hierarchy_view_result = { + "enabled": False, + "reason": "patch_view_failed", + "error": str(exc), + } + else: + hierarchy_view_result = { + "enabled": False, + "reason": "link_field_id_missing", + } + except Exception as exc: + parent_child_link_result = { + "linked_count": 0, + "skipped_count": len(write_records), + "failed_count": 0, + "error": str(exc), + } + hierarchy_view_result = { + "enabled": False, + "reason": "link_field_prepare_failed", + "error": str(exc), + } + + upsert_result = { + "reused_empty_record_count": len(reusable_empty_ids), + "updated_record_count": len(updated_record_ids), + "update_failed_record_ids": update_failed_record_ids, + "created_record_count": len(created_record_ids), + } + record_id = record_ids[0] + field_name = None + write_mode = "native_fields" + fallback_reason = None + else: + auto_created_field_names = [] + matched_field_names = [] + record_ids = [] + field_name = _get_or_create_text_field_name(tenant_access_token, app_token, table_id) + record_value = _normalize_json_text(content) + record_id = _create_record( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields={field_name: record_value}, + ) + write_mode = "json_text" + fallback_reason = "payload is not {'fields': {...}} and has no keys matching table columns" + upsert_result = { + "reused_empty_record_count": 0, + "updated_record_count": 0, + "update_failed_record_ids": [], + "created_record_count": 1, + } + parent_child_link_result = { + "linked_count": 0, + "skipped_count": 0, + "failed_count": 0, + "reason": "json_text_mode", + } + hierarchy_view_result = { + "enabled": False, + "reason": "json_text_mode", + } + + delete_first_10_rows_result = { + "target_count": 10, + "deleted_count": 0, + "failed_record_ids": [], + "skipped": True, + "reason": "disabled_to_avoid_blank_leading_rows_and_timeout", + } + + return { + "app_token": app_token, + "table_id": table_id, + "table_name": table.get("name"), + "record_id": record_id, + "record_ids": record_ids, + "record_count": len(record_ids) if record_ids else 1, + "version": version, + "sync_time": datetime.now(timezone.utc).isoformat(), + "write_mode": write_mode, + "table_field_names": table_field_name_list, + "matched_field_names": matched_field_names, + "auto_created_field_names": auto_created_field_names, + "required_created_field_names": required_created_field_names, + "primary_field_rename": primary_field_rename, + "default_field_cleanup": default_field_cleanup, + "clear_records_result": clear_records_result, + "table_lifecycle_result": { + "created": table_lifecycle_result.get("created", False), + "target_table_name": table_lifecycle_result.get("target_table_name"), + "deleted_table_ids": table_lifecycle_result.get("deleted_table_ids", []), + "delete_failed": table_lifecycle_result.get("delete_failed", []), + }, + "delete_first_10_rows_result": delete_first_10_rows_result, + "upsert_result": upsert_result, + "parent_child_link_result": parent_child_link_result, + "hierarchy_view_result": hierarchy_view_result, + "fallback_reason": fallback_reason, + "field_name": field_name, + "node_token": node.get("node_token"), + "obj_type": node.get("obj_type"), + "title": node.get("title"), + "space_id": SPACE_ID, + "parent_node_token": PARENT_NODE_TOKEN, + "wiki_child_docs_result": wiki_child_docs_result, + } diff --git a/fst_data_pipeline/apps/root_db_api/src/core/feishu_bitable_sdk.py_bak b/fst_data_pipeline/apps/root_db_api/src/core/feishu_bitable_sdk.py_bak new file mode 100644 index 0000000..ac3b90c --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/feishu_bitable_sdk.py_bak @@ -0,0 +1,1321 @@ +import json +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Set + +import requests + +# Hardcoded for temporary integration testing. +APP_ID = "cli_a924d867fe389bc9" +APP_SECRET = "5h1FtUdbgW6qwWV2PzpnjmWNyQc4Ajp5" +SPACE_ID = "7615510038286846937" +PARENT_NODE_TOKEN = "JCKGwmxopi5u3Ikmhsec1k8nnsb" + +AUTH_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" +WIKI_NODES_URL_TMPL = "https://open.feishu.cn/open-apis/wiki/v2/spaces/{space_id}/nodes" +BITABLE_TABLES_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables" +BITABLE_FIELDS_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" +BITABLE_FIELD_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}" +BITABLE_RECORDS_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" +BITABLE_RECORD_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" +BITABLE_RECORDS_BATCH_CREATE_URL_TMPL = ( + "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create" +) +BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL = ( + "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_update" +) +BITABLE_VIEWS_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/views" +BITABLE_VIEW_URL_TMPL = "https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/views/{view_id}" + +TEXT_FIELD_TYPE = 1 +LINK_FIELD_TYPE = 18 +DEFAULT_JSON_FIELD_NAME = "JSON" +REQUIRED_TABLE_FIELDS = ["Name", "Scene", "Level", "Describe"] +# Keep only these columns after initialization; non-primary extras will be removed. +DEFAULT_FIELDS_TO_REMOVE = ["Name", "Scene", "Level", "Describe"] +PRIMARY_FIELD_TARGET_NAME = "Name" +PARENT_LINK_FIELD_NAME = "Parent Record Link" +PARENT_NODE_META_KEY = "__parent_name__" +RECORD_BATCH_SIZE = 200 +RECORD_PAGE_SIZE = 500 +ENABLE_TABLE_FIELD_MAINTENANCE = True +ENABLE_CLEAR_EXISTING_RECORDS = False + + +def _raise_for_business_error(result: Dict[str, Any], action: str) -> None: + code = result.get("code", -1) + if code != 0: + msg = result.get("msg", "unknown error") + raise RuntimeError(f"{action} failed: code={code}, msg={msg}, raw={json.dumps(result, ensure_ascii=False)}") + + +def get_tenant_access_token(app_id: str = APP_ID, app_secret: str = APP_SECRET) -> str: + payload = {"app_id": app_id, "app_secret": app_secret} + headers = {"Content-Type": "application/json; charset=utf-8"} + + response = requests.post(AUTH_URL, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "get_tenant_access_token") + + token = result.get("tenant_access_token") + if not token: + raise RuntimeError(f"get_tenant_access_token failed: token missing, raw={json.dumps(result, ensure_ascii=False)}") + return token + + +def create_bitable_in_wiki( + tenant_access_token: str, + *, + space_id: str = SPACE_ID, + parent_node_token: Optional[str] = PARENT_NODE_TOKEN, + title: Optional[str] = None, +) -> Dict[str, Any]: + url = WIKI_NODES_URL_TMPL.format(space_id=space_id) + headers = { + "Authorization": f"Bearer {tenant_access_token}", + "Content-Type": "application/json; charset=utf-8", + } + + payload: Dict[str, Any] = { + "obj_type": "bitable", + "node_type": "origin", + } + if parent_node_token: + payload["parent_node_token"] = parent_node_token + if title: + payload["title"] = title + + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_in_wiki") + + data = result.get("data") or {} + node = data.get("node") or {} + if not node: + raise RuntimeError(f"create_bitable_in_wiki failed: node missing, raw={json.dumps(result, ensure_ascii=False)}") + return node + + +def _auth_headers(tenant_access_token: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {tenant_access_token}", + "Content-Type": "application/json; charset=utf-8", + } + + +def _get_first_table(tenant_access_token: str, app_token: str) -> Dict[str, Any]: + url = BITABLE_TABLES_URL_TMPL.format(app_token=app_token) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_tables") + + items = ((result.get("data") or {}).get("items") or []) + if not items: + raise RuntimeError(f"list_bitable_tables failed: empty table list, raw={json.dumps(result, ensure_ascii=False)}") + return items[0] + + +def _get_or_create_text_field_name(tenant_access_token: str, app_token: str, table_id: str) -> str: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_fields") + + items = ((result.get("data") or {}).get("items") or []) + for item in items: + if item.get("type") == TEXT_FIELD_TYPE and item.get("field_name"): + return str(item["field_name"]) + + # If no text field exists, create one dedicated to JSON payload. + create_payload = { + "field_name": DEFAULT_JSON_FIELD_NAME, + "type": TEXT_FIELD_TYPE, + } + create_resp = requests.post(url, json=create_payload, headers=_auth_headers(tenant_access_token), timeout=30) + create_resp.raise_for_status() + create_result = create_resp.json() + _raise_for_business_error(create_result, "create_bitable_text_field") + + field = (create_result.get("data") or {}).get("field") or {} + field_name = field.get("field_name") + if not field_name: + raise RuntimeError( + f"create_bitable_text_field failed: field_name missing, raw={json.dumps(create_result, ensure_ascii=False)}" + ) + return field_name + + +def _list_table_field_names(tenant_access_token: str, app_token: str, table_id: str) -> List[str]: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_fields") + + items = ((result.get("data") or {}).get("items") or []) + names: List[str] = [] + for item in items: + name = item.get("field_name") + if isinstance(name, str) and name: + names.append(name) + return names + + +def _list_table_fields(tenant_access_token: str, app_token: str, table_id: str) -> List[Dict[str, Any]]: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_fields") + return ((result.get("data") or {}).get("items") or []) + + +def _create_text_field(tenant_access_token: str, app_token: str, table_id: str, field_name: str) -> None: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "field_name": field_name, + "type": TEXT_FIELD_TYPE, + } + response = requests.post(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_text_field") + + +def _create_link_field(tenant_access_token: str, app_token: str, table_id: str, field_name: str) -> Dict[str, Any]: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "field_name": field_name, + "type": LINK_FIELD_TYPE, + "property": { + "multiple": True, + "table_id": table_id, + }, + } + response = requests.post(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_link_field") + return ((result.get("data") or {}).get("field") or {}) + + +def _ensure_parent_link_field( + tenant_access_token: str, + app_token: str, + table_id: str, + *, + field_name: str = PARENT_LINK_FIELD_NAME, +) -> Dict[str, Any]: + fields = _list_table_fields(tenant_access_token, app_token, table_id) + for field in fields: + if field.get("field_name") == field_name: + if field.get("type") != LINK_FIELD_TYPE: + raise RuntimeError( + f"field '{field_name}' exists but is not linked-record type: got type={field.get('type')}" + ) + return field + + created = _create_link_field(tenant_access_token, app_token, table_id, field_name) + if not created: + # Fall back to list when create API returns empty body in some tenants. + refreshed = _list_table_fields(tenant_access_token, app_token, table_id) + for field in refreshed: + if field.get("field_name") == field_name and field.get("type") == LINK_FIELD_TYPE: + return field + return created + + +def _list_table_views(tenant_access_token: str, app_token: str, table_id: str) -> List[Dict[str, Any]]: + url = BITABLE_VIEWS_URL_TMPL.format(app_token=app_token, table_id=table_id) + response = requests.get(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_views") + return ((result.get("data") or {}).get("items") or []) + + +def _set_hierarchy_view_field( + tenant_access_token: str, + app_token: str, + table_id: str, + view_id: str, + link_field_id: str, +) -> None: + url = BITABLE_VIEW_URL_TMPL.format(app_token=app_token, table_id=table_id, view_id=view_id) + payload = { + "property": { + "hierarchy_config": { + "field_id": link_field_id, + } + } + } + response = requests.patch(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "patch_bitable_view_hierarchy") + + +def _build_ordered_record_ids_after_reuse( + rows: List[Dict[str, Any]], + reusable_record_ids: List[str], + updated_record_ids: List[str], + created_record_ids: List[str], +) -> List[str]: + pair_count = min(len(reusable_record_ids), len(rows)) + updated_id_set = set(updated_record_ids) + created_iter = iter(created_record_ids) + ordered_ids: List[str] = [] + + for rid in reusable_record_ids[:pair_count]: + if rid in updated_id_set: + ordered_ids.append(rid) + else: + ordered_ids.append(next(created_iter)) + + for _ in rows[pair_count:]: + ordered_ids.append(next(created_iter)) + + if len(ordered_ids) != len(rows): + raise RuntimeError( + "reconstruct_record_ids_failed: ordered ids count does not match row count, " + f"rows={len(rows)}, ordered={len(ordered_ids)}" + ) + + return ordered_ids + + +def _link_sub_records_by_parent_name( + tenant_access_token: str, + app_token: str, + table_id: str, + rows: List[Dict[str, Any]], + record_ids_in_row_order: List[str], + link_field_name: str, +) -> Dict[str, Any]: + if len(rows) != len(record_ids_in_row_order): + return { + "linked_count": 0, + "skipped_count": len(rows), + "failed_count": 0, + "error": "rows and record_ids length mismatch", + } + + name_to_record_id: Dict[str, str] = {} + for row, record_id in zip(rows, record_ids_in_row_order): + name = row.get("Name") + if name is None: + name = row.get("Text") + name_text = _stringify_cell_value(name).strip() + if name_text and name_text not in name_to_record_id: + name_to_record_id[name_text] = record_id + + updates: List[Dict[str, Any]] = [] + skipped_count = 0 + for row, record_id in zip(rows, record_ids_in_row_order): + parent_name = _stringify_cell_value(row.get(PARENT_NODE_META_KEY)).strip() + if not parent_name: + parent_name = _stringify_cell_value(row.get("Parent Node")).strip() + if not parent_name: + skipped_count += 1 + continue + parent_record_id = name_to_record_id.get(parent_name) + if not parent_record_id or parent_record_id == record_id: + skipped_count += 1 + continue + updates.append( + { + "record_id": record_id, + "fields": { + link_field_name: [parent_record_id], + }, + } + ) + + if not updates: + return { + "linked_count": 0, + "skipped_count": skipped_count, + "failed_count": 0, + } + + headers = _auth_headers(tenant_access_token) + failed_count = 0 + linked_count = 0 + for start in range(0, len(updates), RECORD_BATCH_SIZE): + chunk = updates[start:start + RECORD_BATCH_SIZE] + url = BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = {"records": chunk} + try: + response = requests.post(url, json=payload, headers=headers, timeout=60) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "batch_update_sub_record_links") + returned = ((result.get("data") or {}).get("records") or []) + linked_count += len(returned) + failed_count += max(0, len(chunk) - len(returned)) + except Exception: + failed_count += len(chunk) + + return { + "linked_count": linked_count, + "skipped_count": skipped_count, + "failed_count": failed_count, + } + + +def _delete_field(tenant_access_token: str, app_token: str, table_id: str, field_id: str) -> None: + url = BITABLE_FIELD_URL_TMPL.format(app_token=app_token, table_id=table_id, field_id=field_id) + response = requests.delete(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "delete_bitable_field") + + +def _rename_field( + tenant_access_token: str, + app_token: str, + table_id: str, + field_id: str, + new_name: str, + field_meta: Optional[Dict[str, Any]] = None, +) -> None: + url = BITABLE_FIELD_URL_TMPL.format(app_token=app_token, table_id=table_id, field_id=field_id) + payload: Dict[str, Any] = {"field_name": new_name} + # Feishu edit field endpoint is more reliable when type/ui_type are preserved. + if field_meta: + field_type = field_meta.get("type") + ui_type = field_meta.get("ui_type") + if isinstance(field_type, int): + payload["type"] = field_type + if isinstance(ui_type, int): + payload["ui_type"] = ui_type + + # Feishu versions differ on PUT/PATCH support; try both and accept first business success. + headers = _auth_headers(tenant_access_token) + last_error: Optional[str] = None + for method in (requests.put, requests.patch): + try: + response = method(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + if result.get("code", -1) == 0: + return + last_error = f"code={result.get('code')}, msg={result.get('msg', 'unknown error')}" + except Exception as exc: + last_error = str(exc) + raise RuntimeError(f"rename_bitable_field failed: {last_error}") + + +def _normalize_json_text(content: Any) -> str: + if isinstance(content, str): + try: + parsed = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"content is not valid JSON string: {exc}") from exc + return json.dumps(parsed, ensure_ascii=False) + return json.dumps(content, ensure_ascii=False) + + +def _stringify_cell_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def _build_records_from_nodes_payload(parsed_content: Any) -> List[Dict[str, Any]]: + if not isinstance(parsed_content, dict): + return [] + nodes = parsed_content.get("nodes") + if not isinstance(nodes, list) or not nodes: + return [] + + # Tree-shaped payload: nodes with nested children. + if any(isinstance(item, dict) and "children" in item for item in nodes): + records: List[Dict[str, Any]] = [] + + def _walk_tree(tree_nodes: List[Dict[str, Any]], parent_name: str) -> None: + for item in tree_nodes: + if not isinstance(item, dict): + continue + + name = item.get("name") + if name is None: + name = item.get("id") + + description = item.get("Description") + if description is None: + description = item.get("description") + if description is None: + description = item.get("label") + + node_level = item.get("treeLevel") + + record_fields = { + "Name": _stringify_cell_value(name), + "Describe": _stringify_cell_value(description), + "Level": _stringify_cell_value(node_level), + PARENT_NODE_META_KEY: _stringify_cell_value(parent_name), + "Scene": _stringify_cell_value(item.get("scene")), + } + records.append(record_fields) + + children = item.get("children") + if isinstance(children, list) and children: + _walk_tree(children, _stringify_cell_value(name)) + + _walk_tree(nodes, "") + return records + + records: List[Dict[str, Any]] = [] + for item in nodes: + if not isinstance(item, dict): + continue + node_id = item.get("id") + if node_id is None: + continue + + description = item.get("name") + if description is None: + description = item.get("label") + if description is None: + description = item.get("Description") + if description is None: + description = item.get("description") + + level = item.get("treeLevel") + + record_fields = { + "Name": _stringify_cell_value(node_id), + "Describe": _stringify_cell_value(description), + "Level": _stringify_cell_value(level), + PARENT_NODE_META_KEY: _stringify_cell_value(item.get("parentId")), + "Scene": _stringify_cell_value(item.get("scene")), + } + records.append(record_fields) + + return records + + +def _extract_native_records(content: Any, table_field_names: Set[str]) -> List[Dict[str, Any]]: + parsed_content = content + if isinstance(content, str): + try: + parsed_content = json.loads(content) + except json.JSONDecodeError: + return [] + + # 1) Supports payload like: {"fields": {...}} + if isinstance(parsed_content, dict): + fields = parsed_content.get("fields") + if isinstance(fields, dict) and fields: + return [fields] + + # 2) Supports payload where top-level keys are field names. + if isinstance(parsed_content, dict): + mapped_fields = { + key: value + for key, value in parsed_content.items() + if isinstance(key, str) and key in table_field_names + } + if mapped_fields: + return [mapped_fields] + + # 3) Supports FST stash format: {"nodes": [...]} and writes one row per node. + rows_from_nodes = _build_records_from_nodes_payload(parsed_content) + if rows_from_nodes: + return rows_from_nodes + + return [] + + +def _strip_internal_meta_fields(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + cleaned: List[Dict[str, Any]] = [] + for row in rows: + cleaned.append({k: v for k, v in row.items() if not (isinstance(k, str) and k.startswith("__"))}) + return cleaned + + +def _ensure_field_names_exist( + tenant_access_token: str, + app_token: str, + table_id: str, + fields: Dict[str, Any], +) -> List[str]: + existing_names = set(_list_table_field_names(tenant_access_token, app_token, table_id)) + created_names: List[str] = [] + for name in fields.keys(): + if not isinstance(name, str) or not name: + continue + if name in existing_names: + continue + _create_text_field(tenant_access_token, app_token, table_id, name) + existing_names.add(name) + created_names.append(name) + return created_names + + +def _ensure_required_table_fields( + tenant_access_token: str, + app_token: str, + table_id: str, + field_names: Optional[List[str]] = None, +) -> List[str]: + """Ensure required business columns exist in table.""" + names = field_names or REQUIRED_TABLE_FIELDS + required_fields = {name: "" for name in names} + return _ensure_field_names_exist( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=required_fields, + ) + + +def _apply_name_alias_to_records(records: List[Dict[str, Any]], table_field_names: Set[str]) -> List[Dict[str, Any]]: + """If Name column is unavailable but Text exists, map Name value to Text.""" + if "Name" in table_field_names or "Text" not in table_field_names: + return records + + mapped_records: List[Dict[str, Any]] = [] + for row in records: + mapped = dict(row) + if "Name" in mapped and "Text" not in mapped: + mapped["Text"] = mapped.pop("Name") + mapped_records.append(mapped) + return mapped_records + + +def _rename_primary_field_to_target( + tenant_access_token: str, + app_token: str, + table_id: str, + target_name: str = PRIMARY_FIELD_TARGET_NAME, +) -> Dict[str, Any]: + """Rename primary field to target name when possible.""" + fields = _list_table_fields(tenant_access_token, app_token, table_id) + name_to_field = { + str(field.get("field_name")): field + for field in fields + if isinstance(field.get("field_name"), str) + } + primary_field = next((field for field in fields if field.get("is_primary")), None) + if not primary_field: + return {"renamed": False, "reason": "primary_field_not_found"} + + primary_name = primary_field.get("field_name") + primary_field_id = primary_field.get("field_id") + if primary_name == target_name: + return {"renamed": False, "reason": "already_target_name"} + + # If target already exists as non-primary, remove it first to avoid name conflict. + existing_target = name_to_field.get(target_name) + removed_conflict = False + if existing_target and not existing_target.get("is_primary"): + existing_target_id = existing_target.get("field_id") + if isinstance(existing_target_id, str) and existing_target_id: + try: + _delete_field(tenant_access_token, app_token, table_id, existing_target_id) + removed_conflict = True + except Exception as exc: + return { + "renamed": False, + "reason": "delete_conflict_field_failed", + "removed_conflict": False, + "error": str(exc), + } + + if not isinstance(primary_field_id, str) or not primary_field_id: + return {"renamed": False, "reason": "primary_field_id_missing", "removed_conflict": removed_conflict} + + try: + _rename_field( + tenant_access_token, + app_token, + table_id, + primary_field_id, + target_name, + primary_field, + ) + except Exception as exc: + return { + "renamed": False, + "reason": "rename_request_failed", + "removed_conflict": removed_conflict, + "error": str(exc), + } + + return { + "renamed": True, + "from": primary_name, + "to": target_name, + "removed_conflict": removed_conflict, + } + + +def _remove_default_table_fields( + tenant_access_token: str, + app_token: str, + table_id: str, +) -> Dict[str, Any]: + """Best-effort cleanup of non-target columns in newly created table.""" + removed: List[str] = [] + skipped_primary: List[str] = [] + failed: List[str] = [] + keep_field_names = set(DEFAULT_FIELDS_TO_REMOVE) + + for field in _list_table_fields(tenant_access_token, app_token, table_id): + name = field.get("field_name") + field_id = field.get("field_id") + is_primary = bool(field.get("is_primary")) + if not isinstance(name, str): + continue + + # Keep required business columns and only clean up extra defaults. + if name in keep_field_names: + continue + + if is_primary: + skipped_primary.append(name) + continue + + if not isinstance(field_id, str) or not field_id: + failed.append(name) + continue + + try: + _delete_field(tenant_access_token, app_token, table_id, field_id) + removed.append(name) + except Exception: + failed.append(name) + + return { + "removed": removed, + "skipped_primary": skipped_primary, + "failed": failed, + } + + +def _create_record( + tenant_access_token: str, + app_token: str, + table_id: str, + fields: Dict[str, Any], +) -> str: + url = BITABLE_RECORDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = {"fields": fields} + response = requests.post(url, json=payload, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "create_bitable_record") + + record_id = (((result.get("data") or {}).get("record") or {}).get("record_id")) + if not record_id: + raise RuntimeError(f"create_bitable_record failed: record_id missing, raw={json.dumps(result, ensure_ascii=False)}") + return record_id + + +def _create_records_batch( + tenant_access_token: str, + app_token: str, + table_id: str, + rows: List[Dict[str, Any]], +) -> List[str]: + if not rows: + return [] + + all_record_ids: List[str] = [] + headers = _auth_headers(tenant_access_token) + + for start in range(0, len(rows), RECORD_BATCH_SIZE): + chunk = rows[start:start + RECORD_BATCH_SIZE] + url = BITABLE_RECORDS_BATCH_CREATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "records": [{"fields": row} for row in chunk], + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=60) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "batch_create_bitable_records") + records = ((result.get("data") or {}).get("records") or []) + chunk_ids = [r.get("record_id") for r in records if isinstance(r, dict) and r.get("record_id")] + if len(chunk_ids) != len(chunk): + raise RuntimeError( + "batch_create_bitable_records failed: record count mismatch, " + f"expect={len(chunk)}, got_ids={len(chunk_ids)}" + ) + all_record_ids.extend(chunk_ids) + except Exception: + # Fallback to single-create if batch endpoint/response is unavailable. + for row in chunk: + all_record_ids.append( + _create_record( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=row, + ) + ) + + return all_record_ids + + +def _list_all_record_ids(tenant_access_token: str, app_token: str, table_id: str) -> List[str]: + url = BITABLE_RECORDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + headers = _auth_headers(tenant_access_token) + page_token = "" + record_ids: List[str] = [] + + while True: + params = {"page_size": RECORD_PAGE_SIZE} + if page_token: + params["page_token"] = page_token + + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_records") + + data = result.get("data") or {} + items = data.get("items") or [] + for item in items: + record_id = item.get("record_id") + if isinstance(record_id, str) and record_id: + record_ids.append(record_id) + + has_more = bool(data.get("has_more")) + if not has_more: + break + page_token = str(data.get("page_token") or "") + if not page_token: + break + + return record_ids + + +def _delete_record(tenant_access_token: str, app_token: str, table_id: str, record_id: str) -> None: + url = BITABLE_RECORD_URL_TMPL.format(app_token=app_token, table_id=table_id, record_id=record_id) + response = requests.delete(url, headers=_auth_headers(tenant_access_token), timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "delete_bitable_record") + + +def _clear_existing_records(tenant_access_token: str, app_token: str, table_id: str) -> Dict[str, Any]: + existing_ids = _list_all_record_ids(tenant_access_token, app_token, table_id) + if not existing_ids: + return {"before_count": 0, "deleted_count": 0, "failed_record_ids": []} + + failed_record_ids: List[str] = [] + for record_id in existing_ids: + try: + _delete_record(tenant_access_token, app_token, table_id, record_id) + except Exception: + failed_record_ids.append(record_id) + + return { + "before_count": len(existing_ids), + "deleted_count": len(existing_ids) - len(failed_record_ids), + "failed_record_ids": failed_record_ids, + } + + +def _delete_first_n_records( + tenant_access_token: str, + app_token: str, + table_id: str, + n: int, +) -> Dict[str, Any]: + if n <= 0: + return {"target_count": 0, "deleted_count": 0, "failed_record_ids": []} + + record_ids = _list_all_record_ids(tenant_access_token, app_token, table_id) + target_ids = record_ids[:n] + failed_record_ids: List[str] = [] + for record_id in target_ids: + try: + _delete_record(tenant_access_token, app_token, table_id, record_id) + except Exception: + failed_record_ids.append(record_id) + + return { + "target_count": len(target_ids), + "deleted_count": len(target_ids) - len(failed_record_ids), + "failed_record_ids": failed_record_ids, + } + + +def _list_records_with_fields(tenant_access_token: str, app_token: str, table_id: str) -> List[Dict[str, Any]]: + url = BITABLE_RECORDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + headers = _auth_headers(tenant_access_token) + page_token = "" + records: List[Dict[str, Any]] = [] + + while True: + params = {"page_size": RECORD_PAGE_SIZE} + if page_token: + params["page_token"] = page_token + + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "list_bitable_records") + + data = result.get("data") or {} + items = data.get("items") or [] + for item in items: + if isinstance(item, dict): + records.append(item) + + if not bool(data.get("has_more")): + break + page_token = str(data.get("page_token") or "") + if not page_token: + break + + return records + + +def _is_empty_business_record(fields: Dict[str, Any]) -> bool: + keys = ["Name", "Text", "Scene", "Level", "Describe"] + for key in keys: + value = fields.get(key) + if value is None: + continue + if isinstance(value, str) and value.strip() == "": + continue + if isinstance(value, (list, dict)) and len(value) == 0: + continue + return False + return True + + +def _find_reusable_empty_record_ids( + tenant_access_token: str, + app_token: str, + table_id: str, + limit: int, +) -> List[str]: + if limit <= 0: + return [] + reusable_ids: List[str] = [] + for item in _list_records_with_fields(tenant_access_token, app_token, table_id): + record_id = item.get("record_id") + fields = item.get("fields") or {} + if not isinstance(record_id, str) or not record_id: + continue + if not isinstance(fields, dict): + continue + if not _is_empty_business_record(fields): + # Only reuse leading empty records to preserve existing real data order. + break + reusable_ids.append(record_id) + if len(reusable_ids) >= limit: + break + return reusable_ids + + +def _update_record( + tenant_access_token: str, + app_token: str, + table_id: str, + record_id: str, + fields: Dict[str, Any], +) -> None: + url = BITABLE_RECORD_URL_TMPL.format(app_token=app_token, table_id=table_id, record_id=record_id) + payload = {"fields": fields} + headers = _auth_headers(tenant_access_token) + + last_error: Optional[str] = None + for method in (requests.put, requests.patch): + try: + response = method(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + if result.get("code", -1) == 0: + return + last_error = f"code={result.get('code')}, msg={result.get('msg', 'unknown error')}" + except Exception as exc: + last_error = str(exc) + raise RuntimeError(f"update_bitable_record failed: {last_error}") + + +def _update_records_reuse_first( + tenant_access_token: str, + app_token: str, + table_id: str, + record_ids: List[str], + rows: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Update reusable empty records first; return rows that still need create.""" + pair_count = min(len(record_ids), len(rows)) + if pair_count <= 0: + return { + "updated_record_ids": [], + "update_failed_record_ids": [], + "remaining_rows": rows, + } + + headers = _auth_headers(tenant_access_token) + updated_record_ids: List[str] = [] + update_failed_record_ids: List[str] = [] + remaining_rows: List[Dict[str, Any]] = [] + + pairs = list(zip(record_ids[:pair_count], rows[:pair_count])) + for start in range(0, len(pairs), RECORD_BATCH_SIZE): + chunk = pairs[start:start + RECORD_BATCH_SIZE] + payload = { + "records": [ + { + "record_id": rid, + "fields": row, + } + for rid, row in chunk + ] + } + url = BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + + try: + response = requests.post(url, json=payload, headers=headers, timeout=60) + response.raise_for_status() + result = response.json() + _raise_for_business_error(result, "batch_update_bitable_records") + returned = ((result.get("data") or {}).get("records") or []) + returned_ids = { + item.get("record_id") + for item in returned + if isinstance(item, dict) and isinstance(item.get("record_id"), str) + } + for rid, row in chunk: + if rid in returned_ids: + updated_record_ids.append(rid) + else: + update_failed_record_ids.append(rid) + remaining_rows.append(row) + except Exception: + # Skip single-update fallback; failed rows will be appended via create. + for rid, row in chunk: + update_failed_record_ids.append(rid) + remaining_rows.append(row) + + # Rows beyond reusable record count should be created. + remaining_rows.extend(rows[pair_count:]) + return { + "updated_record_ids": updated_record_ids, + "update_failed_record_ids": update_failed_record_ids, + "remaining_rows": remaining_rows, + } + + +def create_record_with_fields( + tenant_access_token: str, + app_token: str, + table_id: str, + fields: Dict[str, Any], +) -> str: + """Create one bitable record using native Feishu `fields` payload format.""" + if not isinstance(fields, dict) or not fields: + raise ValueError("fields must be a non-empty dict") + return _create_record( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=fields, + ) + + +def create_bitable_for_version(version: str, content: Any) -> Dict[str, Any]: + """Create a bitable node in wiki and write JSON text record for this version.""" + tenant_access_token = get_tenant_access_token() + node = create_bitable_in_wiki( + tenant_access_token, + title=f"Fst_Editor_{version}", + ) + + app_token = node.get("obj_token") + if not app_token: + raise RuntimeError(f"create_bitable_in_wiki failed: obj_token missing, raw={json.dumps(node, ensure_ascii=False)}") + + table = _get_first_table(tenant_access_token, app_token) + table_id = table.get("table_id") + if not table_id: + raise RuntimeError(f"list_bitable_tables failed: table_id missing, raw={json.dumps(table, ensure_ascii=False)}") + + if ENABLE_CLEAR_EXISTING_RECORDS: + try: + clear_records_result = _clear_existing_records( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + except Exception as exc: + clear_records_result = { + "before_count": -1, + "deleted_count": 0, + "failed_record_ids": [], + "error": str(exc), + } + else: + clear_records_result = { + "before_count": 0, + "deleted_count": 0, + "failed_record_ids": [], + "skipped": True, + } + + if ENABLE_TABLE_FIELD_MAINTENANCE: + table_field_name_before = _list_table_field_names(tenant_access_token, app_token, table_id) + try: + primary_field_rename = _rename_primary_field_to_target( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + except Exception as exc: + primary_field_rename = { + "renamed": False, + "reason": "rename_step_failed", + "error": str(exc), + } + + required_field_names = list(REQUIRED_TABLE_FIELDS) + if not primary_field_rename.get("renamed") and "Text" in table_field_name_before: + required_field_names = ["Text" if name == "Name" else name for name in required_field_names] + + try: + required_created_field_names = _ensure_required_table_fields( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + field_names=required_field_names, + ) + except Exception as exc: + required_created_field_names = [] + primary_field_rename["ensure_required_fields_error"] = str(exc) + + try: + default_field_cleanup = _remove_default_table_fields( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + except Exception as exc: + default_field_cleanup = { + "removed": [], + "skipped_primary": [], + "failed": [], + "error": str(exc), + } + else: + primary_field_rename = {"renamed": False, "reason": "maintenance_disabled"} + required_created_field_names = [] + default_field_cleanup = { + "removed": [], + "skipped_primary": [], + "failed": [], + "skipped": True, + } + + table_field_name_list = _list_table_field_names(tenant_access_token, app_token, table_id) + table_field_names = set(table_field_name_list) + native_records = _extract_native_records(content, table_field_names) + if native_records: + link_source_records = native_records + write_records = _strip_internal_meta_fields(native_records) + write_records = _apply_name_alias_to_records(write_records, table_field_names) + combined_field_names: Dict[str, Any] = {} + for row in write_records: + for key in row.keys(): + combined_field_names[key] = "" + auto_created_field_names = _ensure_field_names_exist( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields=combined_field_names, + ) + matched_field_names = list(combined_field_names.keys()) + reusable_empty_ids = _find_reusable_empty_record_ids( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + limit=len(write_records), + ) + + update_result = _update_records_reuse_first( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + record_ids=reusable_empty_ids, + rows=write_records, + ) + updated_record_ids = update_result["updated_record_ids"] + update_failed_record_ids = update_result["update_failed_record_ids"] + remaining_rows = update_result["remaining_rows"] + + created_record_ids = _create_records_batch( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + rows=remaining_rows, + ) + ordered_record_ids = _build_ordered_record_ids_after_reuse( + rows=write_records, + reusable_record_ids=reusable_empty_ids, + updated_record_ids=updated_record_ids, + created_record_ids=created_record_ids, + ) + record_ids = updated_record_ids + created_record_ids + + try: + parent_link_field = _ensure_parent_link_field( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + parent_link_field_id = parent_link_field.get("field_id") + parent_link_field_name = _stringify_cell_value(parent_link_field.get("field_name") or PARENT_LINK_FIELD_NAME) + + parent_child_link_result = _link_sub_records_by_parent_name( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + rows=link_source_records, + record_ids_in_row_order=ordered_record_ids, + link_field_name=parent_link_field_name, + ) + + hierarchy_view_result: Dict[str, Any] = {"enabled": False, "reason": "view_not_updated"} + if isinstance(parent_link_field_id, str) and parent_link_field_id: + try: + views = _list_table_views( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + first_view = views[0] if views else {} + view_id = first_view.get("view_id") if isinstance(first_view, dict) else None + if isinstance(view_id, str) and view_id: + _set_hierarchy_view_field( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + view_id=view_id, + link_field_id=parent_link_field_id, + ) + hierarchy_view_result = { + "enabled": True, + "view_id": view_id, + "link_field_id": parent_link_field_id, + } + else: + hierarchy_view_result = { + "enabled": False, + "reason": "view_id_not_found", + } + except Exception as exc: + hierarchy_view_result = { + "enabled": False, + "reason": "patch_view_failed", + "error": str(exc), + } + else: + hierarchy_view_result = { + "enabled": False, + "reason": "link_field_id_missing", + } + except Exception as exc: + parent_child_link_result = { + "linked_count": 0, + "skipped_count": len(write_records), + "failed_count": 0, + "error": str(exc), + } + hierarchy_view_result = { + "enabled": False, + "reason": "link_field_prepare_failed", + "error": str(exc), + } + + upsert_result = { + "reused_empty_record_count": len(reusable_empty_ids), + "updated_record_count": len(updated_record_ids), + "update_failed_record_ids": update_failed_record_ids, + "created_record_count": len(created_record_ids), + } + record_id = record_ids[0] + field_name = None + write_mode = "native_fields" + fallback_reason = None + else: + auto_created_field_names = [] + matched_field_names = [] + record_ids = [] + field_name = _get_or_create_text_field_name(tenant_access_token, app_token, table_id) + record_value = _normalize_json_text(content) + record_id = _create_record( + tenant_access_token=tenant_access_token, + app_token=app_token, + table_id=table_id, + fields={field_name: record_value}, + ) + write_mode = "json_text" + fallback_reason = "payload is not {'fields': {...}} and has no keys matching table columns" + upsert_result = { + "reused_empty_record_count": 0, + "updated_record_count": 0, + "update_failed_record_ids": [], + "created_record_count": 1, + } + parent_child_link_result = { + "linked_count": 0, + "skipped_count": 0, + "failed_count": 0, + "reason": "json_text_mode", + } + hierarchy_view_result = { + "enabled": False, + "reason": "json_text_mode", + } + + delete_first_10_rows_result = { + "target_count": 10, + "deleted_count": 0, + "failed_record_ids": [], + "skipped": True, + "reason": "disabled_to_avoid_blank_leading_rows_and_timeout", + } + + return { + "app_token": app_token, + "table_id": table_id, + "record_id": record_id, + "record_ids": record_ids, + "record_count": len(record_ids) if record_ids else 1, + "version": version, + "sync_time": datetime.now(timezone.utc).isoformat(), + "write_mode": write_mode, + "table_field_names": table_field_name_list, + "matched_field_names": matched_field_names, + "auto_created_field_names": auto_created_field_names, + "required_created_field_names": required_created_field_names, + "primary_field_rename": primary_field_rename, + "default_field_cleanup": default_field_cleanup, + "clear_records_result": clear_records_result, + "delete_first_10_rows_result": delete_first_10_rows_result, + "upsert_result": upsert_result, + "parent_child_link_result": parent_child_link_result, + "hierarchy_view_result": hierarchy_view_result, + "fallback_reason": fallback_reason, + "field_name": field_name, + "node_token": node.get("node_token"), + "obj_type": node.get("obj_type"), + "title": node.get("title"), + "space_id": SPACE_ID, + "parent_node_token": PARENT_NODE_TOKEN, + } diff --git a/fst_data_pipeline/apps/root_db_api/src/core/feishu_doc_bitable_block_sdk.py b/fst_data_pipeline/apps/root_db_api/src/core/feishu_doc_bitable_block_sdk.py new file mode 100644 index 0000000..3f8f9b6 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/feishu_doc_bitable_block_sdk.py @@ -0,0 +1,1413 @@ +import json +from typing import Any, Dict, List, Optional, Tuple + +from fst_data_pipeline.apps.root_db_api.src.core.feishu_api_constants import ( + BITABLE_FIELDS_URL_TMPL, + BITABLE_FIELD_URL_TMPL, + BITABLE_RECORDS_URL_TMPL, + BITABLE_RECORD_URL_TMPL, + BITABLE_RECORDS_BATCH_CREATE_URL_TMPL, + BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL, + BITABLE_VIEWS_URL_TMPL, + BITABLE_VIEW_URL_TMPL, + DOCX_BLOCKS_URL_TMPL, + DOCX_BLOCK_CHILDREN_URL_TMPL, + DOC_PAGE_SIZE, + LINK_FIELD_TYPE, + MAX_PAGE_LOOP, + PARENT_LINK_FIELD_NAME, + PRIMARY_FIELD_TARGET_NAME, + RECORD_BATCH_SIZE, + RECORD_PAGE_SIZE, + TARGET_TEXT_FIELDS, + TEXT_FIELD_TYPE, + WIKI_GET_NODE_URL, + WIKI_NODES_URL_TMPL, + build_auth_headers as _auth_headers, + raise_for_business_error as _raise_for_business_error, + request_json_with_retry as _shared_request_json_with_retry, +) + +RETRY_MAX_ATTEMPTS = 3 +RETRY_BACKOFF_SECONDS = 1 + + +def _request_json_with_retry( + method: str, + url: str, + *, + headers: Dict[str, str], + action: str, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: int = 30, +) -> Dict[str, Any]: + return _shared_request_json_with_retry( + method=method, + url=url, + headers=headers, + action=action, + params=params, + payload=payload, + timeout=timeout, + max_attempts=RETRY_MAX_ATTEMPTS, + backoff_seconds=RETRY_BACKOFF_SECONDS, + ) + + +def get_wiki_node_info_by_token(tenant_access_token: str, token: str) -> Dict[str, Any]: + result = _request_json_with_retry( + "GET", + WIKI_GET_NODE_URL, + headers=_auth_headers(tenant_access_token), + params={"token": token}, + action="get_wiki_node_info_by_token", + ) + node = ((result.get("data") or {}).get("node") or {}) + if not isinstance(node, dict) or not node: + raise RuntimeError(f"get_wiki_node_info_by_token failed: node missing, raw={json.dumps(result, ensure_ascii=False)}") + return node + + +def get_child_nodes( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: str, + page_size: int = DOC_PAGE_SIZE, +) -> List[Dict[str, Any]]: + url = WIKI_NODES_URL_TMPL.format(space_id=space_id) + headers = _auth_headers(tenant_access_token) + items: List[Dict[str, Any]] = [] + page_token = "" + loop_count = 0 + + while True: + loop_count += 1 + if loop_count > MAX_PAGE_LOOP: + raise RuntimeError("get_child_nodes exceeded max pagination loops") + + params: Dict[str, Any] = { + "parent_node_token": parent_node_token, + "page_size": page_size, + } + if page_token: + params["page_token"] = page_token + + result = _request_json_with_retry( + "GET", + url, + headers=headers, + params=params, + action="get_child_nodes", + ) + + data = result.get("data") or {} + batch = data.get("items") or [] + for item in batch: + if isinstance(item, dict): + items.append(item) + + if not bool(data.get("has_more")): + break + + next_page_token = str(data.get("page_token") or "") + if not next_page_token or next_page_token == page_token: + break + page_token = next_page_token + + return items + + +def _extract_node_title(node: Dict[str, Any]) -> str: + title = node.get("title") + if isinstance(title, str) and title: + return title + + obj_create_info = node.get("obj_create_info") + if isinstance(obj_create_info, dict): + for key in ("title", "obj_title"): + value = obj_create_info.get(key) + if isinstance(value, str) and value: + return value + + return "Untitled" + + +def _resolve_document_id(tenant_access_token: str, node: Dict[str, Any]) -> Optional[str]: + obj_token = node.get("obj_token") + if isinstance(obj_token, str) and obj_token: + return obj_token + + node_token = node.get("node_token") + if not isinstance(node_token, str) or not node_token: + return None + + try: + detail = get_wiki_node_info_by_token(tenant_access_token, node_token) + except Exception: + return None + + detail_obj_token = detail.get("obj_token") + if isinstance(detail_obj_token, str) and detail_obj_token: + return detail_obj_token + return None + + +def get_all_child_documents( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: str, + depth: int = 0, + max_depth: int = 1, +) -> List[Dict[str, Any]]: + if depth >= max_depth: + return [] + + all_documents: List[Dict[str, Any]] = [] + + child_nodes = get_child_nodes( + tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + ) + + for node in child_nodes: + if not isinstance(node, dict): + continue + + obj_type = str(node.get("obj_type") or "") + if obj_type in {"doc", "docx"}: + document_info = { + "node_token": node.get("node_token"), + "obj_token": _resolve_document_id(tenant_access_token, node), + "title": _extract_node_title(node), + "depth": depth, + "parent_node_token": parent_node_token, + "has_bitable": False, + } + all_documents.append(document_info) + + child_token = node.get("node_token") + if bool(node.get("has_child")) and isinstance(child_token, str) and child_token: + child_documents = get_all_child_documents( + tenant_access_token, + space_id=space_id, + parent_node_token=child_token, + depth=depth + 1, + max_depth=max_depth, + ) + all_documents.extend(child_documents) + + return all_documents + + +def _extract_block_type(block: Dict[str, Any]) -> Optional[int]: + block_type = block.get("block_type") + if isinstance(block_type, int): + return block_type + + inner_block = block.get("block") + if isinstance(inner_block, dict): + inner_type = inner_block.get("block_type") + if isinstance(inner_type, int): + return inner_type + + return None + + +def _extract_block_id(block: Dict[str, Any]) -> Optional[str]: + block_id = block.get("block_id") + if isinstance(block_id, str) and block_id: + return block_id + + inner_block = block.get("block") + if isinstance(inner_block, dict): + inner_block_id = inner_block.get("block_id") + if isinstance(inner_block_id, str) and inner_block_id: + return inner_block_id + + return None + + +def list_document_blocks( + tenant_access_token: str, + *, + document_id: str, + page_size: int = 500, + document_revision_id: int = -1, +) -> List[Dict[str, Any]]: + """List all blocks for a document (with pagination).""" + url = DOCX_BLOCKS_URL_TMPL.format(document_id=document_id) + all_items: List[Dict[str, Any]] = [] + page_token = "" + loop_count = 0 + + while True: + loop_count += 1 + if loop_count > MAX_PAGE_LOOP: + raise RuntimeError("list_document_blocks exceeded max pagination loops") + + params: Dict[str, Any] = { + "document_revision_id": document_revision_id, + "page_size": page_size, + } + if page_token: + params["page_token"] = page_token + + result = _request_json_with_retry( + "GET", + url, + headers=_auth_headers(tenant_access_token), + params=params, + action="list_document_blocks", + ) + + data = result.get("data") or {} + items = data.get("items") or [] + for item in items: + if isinstance(item, dict): + all_items.append(item) + + if not bool(data.get("has_more")): + break + + next_page_token = str(data.get("page_token") or "") + if not next_page_token or next_page_token == page_token: + break + page_token = next_page_token + + return all_items + + +def _resolve_parent_block_id( + tenant_access_token: str, + *, + document_id: str, +) -> str: + """Resolve a valid parent block_id from full document blocks list.""" + items = list_document_blocks( + tenant_access_token, + document_id=document_id, + page_size=500, + document_revision_id=-1, + ) + if not isinstance(items, list) or not items: + raise RuntimeError("resolve_parent_block_id failed: empty blocks") + + # Prefer root block where parent_id is empty. + for item in items: + if not isinstance(item, dict): + continue + if item.get("parent_id") in {"", None}: + block_id = _extract_block_id(item) + if isinstance(block_id, str) and block_id: + return block_id + + # Fallback to first item's block_id. + first = items[0] if isinstance(items[0], dict) else {} + first_block_id = _extract_block_id(first) + if isinstance(first_block_id, str) and first_block_id: + return first_block_id + + raise RuntimeError("resolve_parent_block_id failed: block_id missing") + + +def check_document_has_bitable( + tenant_access_token: str, + *, + document_id: str, +) -> Tuple[bool, Optional[str]]: + blocks = list_document_blocks( + tenant_access_token, + document_id=document_id, + page_size=500, + document_revision_id=-1, + ) + if not blocks: + return False, None + + for block in blocks: + if not isinstance(block, dict): + continue + if _extract_block_type(block) == 18: + block_id = _extract_block_id(block) + return True, block_id + + return False, None + + +def _get_docx_top_block( + tenant_access_token: str, + *, + document_id: str, +) -> Optional[Dict[str, Any]]: + """Return the top content block under the root page block. + + Docx block list often returns the root page block first (block_type=1), + while the real top visible content is root.children[0]. + """ + blocks = list_document_blocks( + tenant_access_token, + document_id=document_id, + page_size=500, + document_revision_id=-1, + ) + if not blocks: + return None + + block_by_id: Dict[str, Dict[str, Any]] = {} + for block in blocks: + if not isinstance(block, dict): + continue + block_id = _extract_block_id(block) + if isinstance(block_id, str) and block_id: + block_by_id[block_id] = block + + root_block = next( + ( + block + for block in blocks + if isinstance(block, dict) and block.get("parent_id") in {"", None} + ), + None, + ) + if not isinstance(root_block, dict): + first = blocks[0] + return first if isinstance(first, dict) else None + + children = root_block.get("children") + if isinstance(children, list) and children: + first_child_id = children[0] + if isinstance(first_child_id, str) and first_child_id: + child_block = block_by_id.get(first_child_id) + if isinstance(child_block, dict): + return child_block + + return root_block + + +def _extract_bitable_token(block: Dict[str, Any]) -> Optional[str]: + bitable = block.get("bitable") + if isinstance(bitable, dict): + token = bitable.get("token") + if isinstance(token, str) and token: + return token + + inner_block = block.get("block") + if isinstance(inner_block, dict): + inner_bitable = inner_block.get("bitable") + if isinstance(inner_bitable, dict): + inner_token = inner_bitable.get("token") + if isinstance(inner_token, str) and inner_token: + return inner_token + + return None + + +def _parse_bitable_token(token: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + if not isinstance(token, str) or not token: + return None, None + parts = token.split("_", 1) + if len(parts) != 2: + return None, None + app_token, table_id = parts[0], parts[1] + if not app_token or not table_id: + return None, None + return app_token, table_id + + +def _list_bitable_fields( + tenant_access_token: str, + *, + app_token: str, + table_id: str, +) -> List[Dict[str, Any]]: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + result = _request_json_with_retry( + "GET", + url, + headers=_auth_headers(tenant_access_token), + action="list_bitable_fields", + ) + return ((result.get("data") or {}).get("items") or []) + + +def _create_text_field( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + field_name: str, +) -> None: + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "field_name": field_name, + "type": TEXT_FIELD_TYPE, + } + _request_json_with_retry( + "POST", + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="create_bitable_text_field", + ) + + +def _update_field_to_text( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + field_id: str, + field_name: str, + field_meta: Optional[Dict[str, Any]] = None, +) -> None: + url = BITABLE_FIELD_URL_TMPL.format(app_token=app_token, table_id=table_id, field_id=field_id) + payload: Dict[str, Any] = { + "field_name": field_name, + "type": TEXT_FIELD_TYPE, + } + if field_meta and isinstance(field_meta.get("ui_type"), int): + payload["ui_type"] = field_meta["ui_type"] + + last_error: Optional[str] = None + for method in ("PUT", "PATCH"): + try: + _request_json_with_retry( + method, + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="update_bitable_field_to_text", + ) + return + except Exception as exc: + last_error = str(exc) + raise RuntimeError(f"update_bitable_field_to_text failed: {last_error}") + + +def _delete_field( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + field_id: str, +) -> None: + url = BITABLE_FIELD_URL_TMPL.format(app_token=app_token, table_id=table_id, field_id=field_id) + _request_json_with_retry( + "DELETE", + url, + headers=_auth_headers(tenant_access_token), + action="delete_bitable_field", + ) + + +def _rename_field( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + field_id: str, + new_name: str, + field_meta: Optional[Dict[str, Any]] = None, +) -> None: + url = BITABLE_FIELD_URL_TMPL.format(app_token=app_token, table_id=table_id, field_id=field_id) + payload: Dict[str, Any] = {"field_name": new_name} + if field_meta: + field_type = field_meta.get("type") + if isinstance(field_type, int): + payload["type"] = field_type + + last_error: Optional[str] = None + for method in ("PUT", "PATCH"): + try: + _request_json_with_retry( + method, + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="rename_bitable_field", + ) + return + except Exception as exc: + last_error = str(exc) + + raise RuntimeError(f"rename_bitable_field failed: {last_error}") + + +def _rename_primary_field_to_target( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + target_name: str = PRIMARY_FIELD_TARGET_NAME, +) -> Dict[str, Any]: + fields = _list_bitable_fields( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + primary_field = next((field for field in fields if field.get("is_primary")), None) + if not isinstance(primary_field, dict): + return {"renamed": False, "reason": "primary_field_not_found"} + + primary_name = primary_field.get("field_name") + primary_field_id = primary_field.get("field_id") + if primary_name == target_name: + return {"renamed": False, "reason": "already_target_name"} + + existing_target = next( + ( + field + for field in fields + if isinstance(field, dict) and field.get("field_name") == target_name and not field.get("is_primary") + ), + None, + ) + removed_conflict = False + if isinstance(existing_target, dict): + conflict_id = existing_target.get("field_id") + if isinstance(conflict_id, str) and conflict_id: + _delete_field( + tenant_access_token, + app_token=app_token, + table_id=table_id, + field_id=conflict_id, + ) + removed_conflict = True + + if not isinstance(primary_field_id, str) or not primary_field_id: + return {"renamed": False, "reason": "primary_field_id_missing", "removed_conflict": removed_conflict} + + _rename_field( + tenant_access_token, + app_token=app_token, + table_id=table_id, + field_id=primary_field_id, + new_name=target_name, + field_meta=primary_field, + ) + return { + "renamed": True, + "from": primary_name, + "to": target_name, + "removed_conflict": removed_conflict, + } + + +def _remove_non_target_fields( + tenant_access_token: str, + *, + app_token: str, + table_id: str, +) -> Dict[str, Any]: + keep_field_names = set(TARGET_TEXT_FIELDS) + removed: List[str] = [] + skipped_primary: List[str] = [] + failed: List[str] = [] + + for field in _list_bitable_fields( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ): + if not isinstance(field, dict): + continue + field_name = field.get("field_name") + field_id = field.get("field_id") + is_primary = bool(field.get("is_primary")) + + if not isinstance(field_name, str): + continue + if field_name in keep_field_names: + continue + if is_primary: + skipped_primary.append(field_name) + continue + if not isinstance(field_id, str) or not field_id: + failed.append(field_name) + continue + + try: + _delete_field( + tenant_access_token, + app_token=app_token, + table_id=table_id, + field_id=field_id, + ) + removed.append(field_name) + except Exception: + failed.append(field_name) + + return { + "removed": removed, + "skipped_primary": skipped_primary, + "failed": failed, + } + + +def _ensure_target_text_fields( + tenant_access_token: str, + *, + app_token: str, + table_id: str, +) -> Dict[str, Any]: + primary_field_rename = { + "renamed": False, + "reason": "not_attempted", + } + created: List[str] = [] + updated_to_text: List[str] = [] + failed: List[Dict[str, str]] = [] + + try: + primary_field_rename = _rename_primary_field_to_target( + tenant_access_token, + app_token=app_token, + table_id=table_id, + target_name=PRIMARY_FIELD_TARGET_NAME, + ) + except Exception as exc: + primary_field_rename = { + "renamed": False, + "reason": "rename_step_failed", + "error": str(exc), + } + + fields = _list_bitable_fields( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + name_to_field = { + str(field.get("field_name")): field + for field in fields + if isinstance(field, dict) and isinstance(field.get("field_name"), str) + } + + for target_name in TARGET_TEXT_FIELDS: + existing = name_to_field.get(target_name) + if isinstance(existing, dict): + field_id = existing.get("field_id") + field_type = existing.get("type") + if field_type == TEXT_FIELD_TYPE: + continue + if not isinstance(field_id, str) or not field_id: + failed.append({"field_name": target_name, "reason": "field_id_missing"}) + continue + try: + _update_field_to_text( + tenant_access_token, + app_token=app_token, + table_id=table_id, + field_id=field_id, + field_name=target_name, + field_meta=existing, + ) + updated_to_text.append(target_name) + except Exception as exc: + failed.append({"field_name": target_name, "reason": str(exc)}) + continue + + try: + _create_text_field( + tenant_access_token, + app_token=app_token, + table_id=table_id, + field_name=target_name, + ) + created.append(target_name) + except Exception as exc: + failed.append({"field_name": target_name, "reason": str(exc)}) + + cleanup = { + "removed": [], + "skipped_primary": [], + "failed": [], + } + try: + cleanup = _remove_non_target_fields( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + except Exception as exc: + cleanup = { + "removed": [], + "skipped_primary": [], + "failed": [], + "error": str(exc), + } + + return { + "target_fields": TARGET_TEXT_FIELDS, + "primary_field_rename": primary_field_rename, + "created": created, + "updated_to_text": updated_to_text, + "failed": failed, + "cleanup": cleanup, + } + + +def create_bitable_block( + tenant_access_token: str, + *, + document_id: str, + bitable_app_token: Optional[str] = None, + position: str = "beginning", +) -> Dict[str, Any]: + parent_block_id = _resolve_parent_block_id( + tenant_access_token, + document_id=document_id, + ) + url = DOCX_BLOCK_CHILDREN_URL_TMPL.format(document_id=document_id, block_id=parent_block_id) + block_index = 0 if position == "beginning" else -1 + + payload = { + "children": [ + { + "block_type": 18, + "bitable": { + "view_type": 1, + }, + } + ], + "index": block_index, + } + + result = _request_json_with_retry( + "POST", + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="create_bitable_block", + ) + + data = result.get("data") or {} + children = data.get("children") or [] + created_block = children[0] if children and isinstance(children[0], dict) else (data.get("block") or {}) + + sync_result: Dict[str, Any] = { + "skipped": True, + "reason": "bitable_token_or_table_id_missing", + } + try: + token = _extract_bitable_token(created_block) + token_app, table_id = _parse_bitable_token(token) + resolved_app_token = token_app or bitable_app_token + if isinstance(resolved_app_token, str) and resolved_app_token and isinstance(table_id, str) and table_id: + sync_result = _ensure_target_text_fields( + tenant_access_token, + app_token=resolved_app_token, + table_id=table_id, + ) + else: + sync_result = { + "skipped": True, + "reason": "bitable_token_or_table_id_missing", + "bitable_token": token, + } + except Exception as exc: + sync_result = { + "skipped": False, + "error": str(exc), + } + + if isinstance(created_block, dict): + created_block["field_sync_result"] = sync_result + + return created_block + + +def sync_document_bitable_fields( + tenant_access_token: str, + *, + document_id: str, + bitable_app_token: Optional[str] = None, +) -> Dict[str, Any]: + blocks = list_document_blocks( + tenant_access_token, + document_id=document_id, + page_size=500, + document_revision_id=-1, + ) + + first_bitable = next( + ( + block + for block in blocks + if isinstance(block, dict) and _extract_block_type(block) == 18 + ), + None, + ) + if not isinstance(first_bitable, dict): + return { + "skipped": True, + "reason": "bitable_block_not_found", + } + + token = _extract_bitable_token(first_bitable) + token_app, table_id = _parse_bitable_token(token) + resolved_app_token = token_app or bitable_app_token + if not isinstance(resolved_app_token, str) or not resolved_app_token or not isinstance(table_id, str) or not table_id: + return { + "skipped": True, + "reason": "bitable_token_or_table_id_missing", + "bitable_token": token, + } + + return _ensure_target_text_fields( + tenant_access_token, + app_token=resolved_app_token, + table_id=table_id, + ) + + +def _list_bitable_records( + tenant_access_token: str, + *, + app_token: str, + table_id: str, +) -> List[Dict[str, Any]]: + url = BITABLE_RECORDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + page_token = "" + loop_count = 0 + items: List[Dict[str, Any]] = [] + + while True: + loop_count += 1 + if loop_count > MAX_PAGE_LOOP: + raise RuntimeError("list_bitable_records exceeded max pagination loops") + + params: Dict[str, Any] = {"page_size": RECORD_PAGE_SIZE} + if page_token: + params["page_token"] = page_token + + result = _request_json_with_retry( + "GET", + url, + headers=_auth_headers(tenant_access_token), + params=params, + action="list_bitable_records", + ) + data = result.get("data") or {} + batch = data.get("items") or [] + for item in batch: + if isinstance(item, dict): + items.append(item) + + if not bool(data.get("has_more")): + break + + next_page_token = str(data.get("page_token") or "") + if not next_page_token or next_page_token == page_token: + break + page_token = next_page_token + + return items + + +def _delete_bitable_record( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + record_id: str, +) -> None: + url = BITABLE_RECORD_URL_TMPL.format(app_token=app_token, table_id=table_id, record_id=record_id) + _request_json_with_retry( + "DELETE", + url, + headers=_auth_headers(tenant_access_token), + action="delete_bitable_record", + ) + + +def _clear_bitable_records( + tenant_access_token: str, + *, + app_token: str, + table_id: str, +) -> Dict[str, Any]: + records = _list_bitable_records( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + record_ids = [ + item.get("record_id") + for item in records + if isinstance(item, dict) and isinstance(item.get("record_id"), str) + ] + failed: List[str] = [] + for record_id in record_ids: + try: + _delete_bitable_record( + tenant_access_token, + app_token=app_token, + table_id=table_id, + record_id=record_id, + ) + except Exception: + failed.append(record_id) + + return { + "before_count": len(record_ids), + "deleted_count": len(record_ids) - len(failed), + "failed_record_ids": failed, + } + + +def _create_records_batch( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + rows: List[Dict[str, Any]], +) -> List[str]: + if not rows: + return [] + + all_record_ids: List[str] = [] + url = BITABLE_RECORDS_BATCH_CREATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + headers = _auth_headers(tenant_access_token) + for start in range(0, len(rows), RECORD_BATCH_SIZE): + chunk = rows[start:start + RECORD_BATCH_SIZE] + payload = {"records": [{"fields": row} for row in chunk]} + result = _request_json_with_retry( + "POST", + url, + headers=headers, + payload=payload, + action="batch_create_bitable_records", + ) + created = ((result.get("data") or {}).get("records") or []) + chunk_ids = [ + item.get("record_id") + for item in created + if isinstance(item, dict) and isinstance(item.get("record_id"), str) + ] + if len(chunk_ids) != len(chunk): + raise RuntimeError( + "batch_create_bitable_records mismatch: " + f"expect={len(chunk)}, got={len(chunk_ids)}" + ) + all_record_ids.extend(chunk_ids) + + return all_record_ids + + +def _ensure_parent_link_field( + tenant_access_token: str, + *, + app_token: str, + table_id: str, +) -> Dict[str, Any]: + fields = _list_bitable_fields( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + for field in fields: + if field.get("field_name") == PARENT_LINK_FIELD_NAME: + return field + + url = BITABLE_FIELDS_URL_TMPL.format(app_token=app_token, table_id=table_id) + payload = { + "field_name": PARENT_LINK_FIELD_NAME, + "type": LINK_FIELD_TYPE, + "property": { + "multiple": True, + "table_id": table_id, + }, + } + result = _request_json_with_retry( + "POST", + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="create_parent_link_field", + ) + return ((result.get("data") or {}).get("field") or {}) + + +def _list_table_views( + tenant_access_token: str, + *, + app_token: str, + table_id: str, +) -> List[Dict[str, Any]]: + url = BITABLE_VIEWS_URL_TMPL.format(app_token=app_token, table_id=table_id) + result = _request_json_with_retry( + "GET", + url, + headers=_auth_headers(tenant_access_token), + action="list_bitable_views", + ) + return ((result.get("data") or {}).get("items") or []) + + +def _set_hierarchy_view_field( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + view_id: str, + link_field_id: str, +) -> None: + url = BITABLE_VIEW_URL_TMPL.format(app_token=app_token, table_id=table_id, view_id=view_id) + payload = { + "property": { + "hierarchy_config": { + "field_id": link_field_id, + } + } + } + _request_json_with_retry( + "PATCH", + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="patch_bitable_view_hierarchy", + ) + + +def _stringify(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def _normalize_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + normalized: List[Dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + normalized.append( + { + "Name": _stringify(row.get("Name")).strip(), + "Scene": _stringify(row.get("Scene")).strip(), + "Level": _stringify(row.get("Level")).strip(), + "Describe": _stringify(row.get("Describe")).strip(), + "__parent_name__": _stringify(row.get("__parent_name__")).strip(), + } + ) + return normalized + + +def _link_records_by_parent_name( + tenant_access_token: str, + *, + app_token: str, + table_id: str, + rows: List[Dict[str, Any]], + record_ids: List[str], +) -> Dict[str, Any]: + if len(rows) != len(record_ids): + return { + "linked_count": 0, + "failed_count": 0, + "skipped_count": len(rows), + "reason": "rows_record_mismatch", + } + + parent_field = _ensure_parent_link_field( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + parent_field_id = parent_field.get("field_id") + parent_field_name = _stringify(parent_field.get("field_name") or PARENT_LINK_FIELD_NAME) + + name_to_record_id: Dict[str, str] = {} + for row, record_id in zip(rows, record_ids): + name = _stringify(row.get("Name")).strip() + if name and name not in name_to_record_id: + name_to_record_id[name] = record_id + + updates: List[Dict[str, Any]] = [] + skipped_count = 0 + for row, record_id in zip(rows, record_ids): + parent_name = _stringify(row.get("__parent_name__")).strip() + if not parent_name: + skipped_count += 1 + continue + parent_record_id = name_to_record_id.get(parent_name) + if not parent_record_id or parent_record_id == record_id: + skipped_count += 1 + continue + updates.append( + { + "record_id": record_id, + "fields": { + parent_field_name: [parent_record_id], + }, + } + ) + + if not updates: + return { + "linked_count": 0, + "failed_count": 0, + "skipped_count": skipped_count, + } + + url = BITABLE_RECORDS_BATCH_UPDATE_URL_TMPL.format(app_token=app_token, table_id=table_id) + linked_count = 0 + failed_count = 0 + for start in range(0, len(updates), RECORD_BATCH_SIZE): + chunk = updates[start:start + RECORD_BATCH_SIZE] + payload = {"records": chunk} + try: + result = _request_json_with_retry( + "POST", + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="batch_update_parent_links", + ) + returned = ((result.get("data") or {}).get("records") or []) + linked_count += len(returned) + failed_count += max(0, len(chunk) - len(returned)) + except Exception: + failed_count += len(chunk) + + return { + "linked_count": linked_count, + "failed_count": failed_count, + "skipped_count": skipped_count, + "link_field_id": parent_field_id, + "link_field_name": parent_field_name, + } + + +def sync_document_bitable_rows( + tenant_access_token: str, + *, + document_id: str, + rows: List[Dict[str, Any]], + bitable_app_token: Optional[str] = None, +) -> Dict[str, Any]: + blocks = list_document_blocks( + tenant_access_token, + document_id=document_id, + page_size=500, + document_revision_id=-1, + ) + first_bitable = next( + ( + block + for block in blocks + if isinstance(block, dict) and _extract_block_type(block) == 18 + ), + None, + ) + if not isinstance(first_bitable, dict): + return { + "synced": False, + "reason": "bitable_block_not_found", + } + + token = _extract_bitable_token(first_bitable) + token_app, table_id = _parse_bitable_token(token) + app_token = token_app or bitable_app_token + if not isinstance(app_token, str) or not app_token or not isinstance(table_id, str) or not table_id: + return { + "synced": False, + "reason": "bitable_token_or_table_id_missing", + "bitable_token": token, + } + + field_sync_result = _ensure_target_text_fields( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + + normalized_rows = _normalize_rows(rows) + clear_result = _clear_bitable_records( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + + write_rows = [ + { + "Name": row["Name"], + "Scene": row["Scene"], + "Level": row["Level"], + "Describe": row["Describe"], + } + for row in normalized_rows + ] + record_ids = _create_records_batch( + tenant_access_token, + app_token=app_token, + table_id=table_id, + rows=write_rows, + ) + link_result = _link_records_by_parent_name( + tenant_access_token, + app_token=app_token, + table_id=table_id, + rows=normalized_rows, + record_ids=record_ids, + ) if normalized_rows else { + "linked_count": 0, + "failed_count": 0, + "skipped_count": 0, + "reason": "no_rows", + } + + hierarchy_view_result: Dict[str, Any] = {"enabled": False, "reason": "not_configured"} + link_field_id = link_result.get("link_field_id") + if isinstance(link_field_id, str) and link_field_id: + try: + views = _list_table_views( + tenant_access_token, + app_token=app_token, + table_id=table_id, + ) + first_view = views[0] if views else {} + view_id = first_view.get("view_id") if isinstance(first_view, dict) else None + if isinstance(view_id, str) and view_id: + _set_hierarchy_view_field( + tenant_access_token, + app_token=app_token, + table_id=table_id, + view_id=view_id, + link_field_id=link_field_id, + ) + hierarchy_view_result = { + "enabled": True, + "view_id": view_id, + "link_field_id": link_field_id, + } + else: + hierarchy_view_result = { + "enabled": False, + "reason": "view_id_not_found", + } + except Exception as exc: + hierarchy_view_result = { + "enabled": False, + "reason": "patch_view_failed", + "error": str(exc), + } + + return { + "synced": True, + "document_id": document_id, + "app_token": app_token, + "table_id": table_id, + "row_count": len(write_rows), + "record_count": len(record_ids), + "field_sync_result": field_sync_result, + "clear_result": clear_result, + "link_result": link_result, + "hierarchy_view_result": hierarchy_view_result, + } + + +def ensure_bitable_block_for_document( + tenant_access_token: str, + *, + document_id: str, +) -> Dict[str, Any]: + top_block = _get_docx_top_block( + tenant_access_token, + document_id=document_id, + ) + + if isinstance(top_block, dict) and _extract_block_type(top_block) == 18: + block_id = _extract_block_id(top_block) + field_sync_result = sync_document_bitable_fields( + tenant_access_token, + document_id=document_id, + ) + return { + "mode": "unchanged", + "document_id": document_id, + "block_id": block_id, + "field_sync_result": field_sync_result, + } + + created = create_bitable_block( + tenant_access_token, + document_id=document_id, + position="beginning", + ) + return { + "mode": "created", + "document_id": document_id, + "block_id": _extract_block_id(created), + "block": created, + } + + +def ensure_bitable_for_all_child_documents( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: str, + max_depth: int = 5, + continue_on_error: bool = True, +) -> Dict[str, Any]: + documents = get_all_child_documents( + tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + depth=0, + max_depth=max_depth, + ) + + results: List[Dict[str, Any]] = [] + failed: List[Dict[str, Any]] = [] + + for doc in documents: + document_id = doc.get("obj_token") + if not isinstance(document_id, str) or not document_id: + failed.append( + { + "title": doc.get("title"), + "node_token": doc.get("node_token"), + "error": "document obj_token missing", + } + ) + if not continue_on_error: + break + continue + + try: + applied = ensure_bitable_block_for_document( + tenant_access_token, + document_id=document_id, + ) + merged = dict(doc) + merged["has_bitable"] = True + merged["apply_result"] = applied + results.append(merged) + except Exception as exc: + failed.append( + { + "title": doc.get("title"), + "node_token": doc.get("node_token"), + "obj_token": document_id, + "error": str(exc), + } + ) + if not continue_on_error: + break + + return { + "document_count": len(documents), + "processed_count": len(results) + len(failed), + "success_count": len(results), + "failed_count": len(failed), + "results": results, + "failed": failed, + } diff --git a/fst_data_pipeline/apps/root_db_api/src/core/feishu_wiki_doc_sdk.py b/fst_data_pipeline/apps/root_db_api/src/core/feishu_wiki_doc_sdk.py new file mode 100644 index 0000000..2fe848a --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/feishu_wiki_doc_sdk.py @@ -0,0 +1,835 @@ +import json +from typing import Any, Dict, List, Optional, Set + +from fst_data_pipeline.apps.root_db_api.src.core.feishu_api_constants import ( + DOC_PAGE_SIZE, + MAX_PAGE_LOOP, + WIKI_GET_NODE_URL, + WIKI_NODES_URL_TMPL, + build_auth_headers as _auth_headers, + raise_for_business_error as _raise_for_business_error, + request_json_with_retry as _shared_request_json_with_retry, +) +from fst_data_pipeline.apps.root_db_api.src.core.feishu_doc_bitable_block_sdk import ( + ensure_bitable_block_for_document, + sync_document_bitable_rows, +) + +RETRY_MAX_ATTEMPTS = 3 +RETRY_BACKOFF_SECONDS = 1 +FST_EDITOR_DOC_TITLE = "Fst_Editor" +DOC_TREE_MAX_DEPTH = 5 +CONTENT_WRAP_KEYS = ("content", "data", "payload", "body", "result") + + +def _request_json_with_retry( + method: str, + url: str, + *, + headers: Dict[str, str], + action: str, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: int = 30, +) -> Dict[str, Any]: + return _shared_request_json_with_retry( + method=method, + url=url, + headers=headers, + action=action, + params=params, + payload=payload, + timeout=timeout, + max_attempts=RETRY_MAX_ATTEMPTS, + backoff_seconds=RETRY_BACKOFF_SECONDS, + ) + + +def _extract_node_title(node: Dict[str, Any]) -> str: + title = node.get("title") + if isinstance(title, str): + return title + + obj_create_info = node.get("obj_create_info") + if isinstance(obj_create_info, dict): + inner_title = obj_create_info.get("title") + if isinstance(inner_title, str): + return inner_title + inner_obj_title = obj_create_info.get("obj_title") + if isinstance(inner_obj_title, str): + return inner_obj_title + return "" + + +def _parse_content_object(content: Any) -> Any: + if isinstance(content, str): + try: + return json.loads(content) + except json.JSONDecodeError: + return None + return content + + +def _resolve_nodes_owner(parsed: Any, depth: int = 0) -> Optional[Dict[str, Any]]: + if depth > 6: + return None + + if isinstance(parsed, dict): + nodes = parsed.get("nodes") + if isinstance(nodes, list): + return parsed + + for key in CONTENT_WRAP_KEYS: + inner = parsed.get(key) + owner = _resolve_nodes_owner(inner, depth + 1) + if isinstance(owner, dict): + return owner + + if isinstance(parsed, list): + return {"nodes": parsed} + + return None + + +def _extract_first_level_names(content: Any) -> List[str]: + parsed = _parse_content_object(content) + owner = _resolve_nodes_owner(parsed) + if not isinstance(owner, dict): + return [] + + nodes = owner.get("nodes") + if not isinstance(nodes, list): + return [] + + has_tree_level = any(isinstance(item, dict) and item.get("treeLevel") is not None for item in nodes) + + names: List[str] = [] + seen: Set[str] = set() + for item in nodes: + if not isinstance(item, dict): + continue + if _is_deleted_node(item): + continue + if has_tree_level and item.get("treeLevel") != 1: + continue + for key in ("name", "Name", "label", "id"): + value = item.get(key) + if value is None: + continue + text = str(value).strip() + if text and text not in seen: + seen.add(text) + names.append(text) + break + return names + + +def debug_extract_first_level_names(content: Any) -> Dict[str, Any]: + """Return diagnostic details for first-level name extraction.""" + parsed = content + parse_error: Optional[str] = None + if isinstance(content, str): + try: + parsed = json.loads(content) + except json.JSONDecodeError as exc: + parse_error = str(exc) + parsed = None + + owner = _resolve_nodes_owner(parsed) + node_count = 0 + tree_level_counts: Dict[str, int] = {} + root_keys: List[str] = list(parsed.keys()) if isinstance(parsed, dict) else [] + owner_keys: List[str] = list(owner.keys()) if isinstance(owner, dict) else [] + if isinstance(owner, dict): + nodes = owner.get("nodes") + if isinstance(nodes, list): + node_count = len(nodes) + for item in nodes: + if not isinstance(item, dict): + continue + level = item.get("treeLevel") + level_key = "null" if level is None else str(level) + tree_level_counts[level_key] = tree_level_counts.get(level_key, 0) + 1 + + names = _extract_first_level_names(content) + return { + "names": names, + "name_count": len(names), + "node_count": node_count, + "tree_level_counts": tree_level_counts, + "parse_error": parse_error, + "resolved_nodes_owner": bool(isinstance(owner, dict)), + "root_keys": root_keys, + "owner_keys": owner_keys, + } + + +def _extract_item_title(item: Dict[str, Any]) -> str: + for key in ("title", "name", "Name", "label", "id"): + value = item.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + +def _is_deleted_node(item: Dict[str, Any]) -> bool: + deleted = item.get("deleted") + if isinstance(deleted, bool): + return deleted + if isinstance(deleted, int): + return deleted == 1 + if isinstance(deleted, str): + return deleted.strip() in {"1", "true", "True"} + return False + + +def _stringify_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def _extract_item_describe(item: Dict[str, Any]) -> str: + for key in ("Description", "description", "label", "name", "Name"): + value = item.get(key) + if value is None: + continue + text = _stringify_value(value).strip() + if text: + return text + return "" + + +def _extract_item_scene(item: Dict[str, Any]) -> str: + return _stringify_value(item.get("scene")).strip() + + +def _build_rows_by_first_level(content: Any) -> Dict[str, List[Dict[str, Any]]]: + parsed = _parse_content_object(content) + owner = _resolve_nodes_owner(parsed) + if not isinstance(owner, dict): + return {} + + nodes = owner.get("nodes") + if not isinstance(nodes, list) or not nodes: + return {} + + result: Dict[str, List[Dict[str, Any]]] = {} + + # Tree payload: each level-1 node has nested children. + if any(isinstance(node, dict) and isinstance(node.get("children"), list) for node in nodes): + def _walk_tree( + node: Dict[str, Any], + *, + root_name: str, + parent_name: str, + fallback_level: int, + ) -> None: + if _is_deleted_node(node): + return + + name = _extract_item_title(node) + if not name: + return + + level_value = node.get("treeLevel") + level_text = _stringify_value(level_value if level_value is not None else fallback_level) + result.setdefault(root_name, []).append( + { + "Name": name, + "Scene": _extract_item_scene(node), + "Level": level_text, + "Describe": _extract_item_describe(node), + "__parent_name__": parent_name, + } + ) + + children = node.get("children") + if isinstance(children, list): + for child in children: + if isinstance(child, dict): + _walk_tree( + child, + root_name=root_name, + parent_name=name, + fallback_level=fallback_level + 1, + ) + + for node in nodes: + if not isinstance(node, dict): + continue + if _is_deleted_node(node): + continue + root_name = _extract_item_title(node) + if not root_name: + continue + _walk_tree( + node, + root_name=root_name, + parent_name="", + fallback_level=1, + ) + + return result + + # Flat payload with treeLevel + parentId. + id_to_item: Dict[str, Dict[str, Any]] = {} + id_to_name: Dict[str, str] = {} + first_level_ids: Set[str] = set() + for node in nodes: + if not isinstance(node, dict): + continue + if _is_deleted_node(node): + continue + node_id = _stringify_value(node.get("id")).strip() + node_name = _extract_item_title(node) + if not node_id or not node_name: + continue + id_to_item[node_id] = node + id_to_name[node_id] = node_name + if node.get("treeLevel") == 1: + first_level_ids.add(node_id) + + for node_id, node in id_to_item.items(): + if _is_deleted_node(node): + continue + current_id = node_id + parent_id = _stringify_value(node.get("parentId")).strip() + root_id = current_id if current_id in first_level_ids else "" + visited: Set[str] = set() + + while parent_id: + if parent_id in visited: + break + visited.add(parent_id) + if parent_id in first_level_ids: + root_id = parent_id + break + parent_node = id_to_item.get(parent_id) + if not isinstance(parent_node, dict): + break + parent_id = _stringify_value(parent_node.get("parentId")).strip() + + if not root_id: + continue + + root_name = id_to_name.get(root_id) + if not isinstance(root_name, str) or not root_name: + continue + + parent_id_text = _stringify_value(node.get("parentId")).strip() + level_value = node.get("treeLevel") + level_text = _stringify_value(level_value if level_value is not None else "") + result.setdefault(root_name, []).append( + { + # Keep flat payload behavior consistent with feishu_bitable_sdk: + # Name uses node id and parent link key uses parent id. + "Name": node_id, + "Scene": _extract_item_scene(node), + "Level": level_text, + "Describe": _extract_item_describe(node), + "__parent_name__": parent_id_text, + } + ) + + return result + + +def _extract_doc_structure(content: Any) -> List[Dict[str, Any]]: + parsed = _parse_content_object(content) + owner = _resolve_nodes_owner(parsed) + if not isinstance(owner, dict): + return [] + + raw_nodes = owner.get("nodes") + if not isinstance(raw_nodes, list): + return [] + + # Flat payload with treeLevel should only create top-level docs. + if all(isinstance(node, dict) and "children" not in node for node in raw_nodes): + first_level_names = _extract_first_level_names(owner) + return [{"title": title, "children": []} for title in first_level_names] + + def _normalize_nodes(nodes: List[Any], depth: int) -> List[Dict[str, Any]]: + if depth >= DOC_TREE_MAX_DEPTH: + return [] + + normalized: List[Dict[str, Any]] = [] + for node in nodes: + if not isinstance(node, dict): + continue + title = _extract_item_title(node) + if not title: + continue + + children = node.get("children") + child_docs = _normalize_nodes(children, depth + 1) if isinstance(children, list) else [] + normalized.append( + { + "title": title, + "children": child_docs, + } + ) + return normalized + + return _normalize_nodes(raw_nodes, 0) + + +def get_wiki_node_info_by_token(tenant_access_token: str, token: str) -> Dict[str, Any]: + result = _request_json_with_retry( + "GET", + WIKI_GET_NODE_URL, + headers=_auth_headers(tenant_access_token), + params={"token": token}, + action="get_wiki_node_info_by_token", + ) + node = ((result.get("data") or {}).get("node") or {}) + if not isinstance(node, dict) or not node: + raise RuntimeError(f"get_wiki_node_info_by_token failed: node missing, raw={json.dumps(result, ensure_ascii=False)}") + return node + + +def list_wiki_child_nodes( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: Optional[str], +) -> List[Dict[str, Any]]: + url = WIKI_NODES_URL_TMPL.format(space_id=space_id) + headers = _auth_headers(tenant_access_token) + page_token = "" + items: List[Dict[str, Any]] = [] + seen_tokens: Set[str] = set() + loop_count = 0 + + while True: + loop_count += 1 + if loop_count > MAX_PAGE_LOOP: + raise RuntimeError("list_wiki_child_nodes exceeded max pagination loops") + + params: Dict[str, Any] = { + "page_size": DOC_PAGE_SIZE, + } + if parent_node_token: + params["parent_node_token"] = parent_node_token + if page_token: + params["page_token"] = page_token + + result = _request_json_with_retry( + "GET", + url, + headers=headers, + params=params, + action="list_wiki_child_nodes", + ) + data = result.get("data") or {} + batch = data.get("items") or [] + for item in batch: + if isinstance(item, dict): + items.append(item) + + if not bool(data.get("has_more")): + break + + next_page_token = str(data.get("page_token") or "") + if not next_page_token or next_page_token == page_token or next_page_token in seen_tokens: + break + seen_tokens.add(next_page_token) + page_token = next_page_token + + return items + + +def get_all_descendant_nodes( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: str, + depth: int = 0, + max_depth: int = 20, +) -> List[Dict[str, Any]]: + """Recursively list all descendant nodes under a parent node.""" + if depth > max_depth: + raise RuntimeError("get_all_descendant_nodes exceeded max_depth") + + descendants: List[Dict[str, Any]] = [] + child_nodes = list_wiki_child_nodes( + tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + ) + + for node in child_nodes: + if not isinstance(node, dict): + continue + + node_with_depth = dict(node) + node_with_depth["depth"] = depth + node_with_depth["parent_token"] = parent_node_token + descendants.append(node_with_depth) + + child_token = node.get("node_token") + if not isinstance(child_token, str) or not child_token: + continue + + has_child = bool(node.get("has_child", False)) + if not has_child: + continue + + sub_descendants = get_all_descendant_nodes( + tenant_access_token, + space_id=space_id, + parent_node_token=child_token, + depth=depth + 1, + max_depth=max_depth, + ) + descendants.extend(sub_descendants) + + return descendants + + +def create_child_docx( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: str, + title: str, +) -> Dict[str, Any]: + url = WIKI_NODES_URL_TMPL.format(space_id=space_id) + payload = { + "obj_type": "docx", + "node_type": "origin", + "parent_node_token": parent_node_token, + "title": title, + } + result = _request_json_with_retry( + "POST", + url, + headers=_auth_headers(tenant_access_token), + payload=payload, + action="create_child_docx", + ) + node = ((result.get("data") or {}).get("node") or {}) + if not isinstance(node, dict) or not node: + raise RuntimeError(f"create_child_docx failed: node missing, raw={json.dumps(result, ensure_ascii=False)}") + return node + + +def _ensure_doc_tree_under_parent( + tenant_access_token: str, + *, + space_id: str, + parent_node_token: str, + doc_items: List[Dict[str, Any]], + current_depth: int = 0, + max_depth: int = DOC_TREE_MAX_DEPTH, +) -> Dict[str, Any]: + if current_depth >= max_depth: + return { + "created": [], + "existing": [], + "failed": [], + "max_depth_reached": True, + } + + direct_children = list_wiki_child_nodes( + tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + ) + direct_title_to_node = { + _extract_node_title(node).strip(): node + for node in direct_children + if isinstance(node, dict) and _extract_node_title(node).strip() + } + + created: List[Dict[str, Any]] = [] + existing: List[Dict[str, Any]] = [] + failed: List[Dict[str, Any]] = [] + max_depth_reached = False + + for item in doc_items: + title = _extract_item_title(item) + if not title: + failed.append( + { + "title": "", + "parent_token": parent_node_token, + "depth": current_depth, + "error": "empty_title", + } + ) + continue + + target_node = direct_title_to_node.get(title) + source = "existing" + if not isinstance(target_node, dict): + try: + target_node = create_child_docx( + tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + title=title, + ) + source = "created" + direct_title_to_node[title] = target_node + except Exception as exc: + failed.append( + { + "title": title, + "parent_token": parent_node_token, + "depth": current_depth, + "error": str(exc), + } + ) + continue + + entry = { + "title": title, + "node_token": target_node.get("node_token"), + "obj_token": target_node.get("obj_token"), + "parent_token": parent_node_token, + "depth": current_depth, + } + if source == "created": + created.append(entry) + else: + existing.append(entry) + + children = item.get("children") + if isinstance(children, list) and children: + child_token = target_node.get("node_token") + if isinstance(child_token, str) and child_token: + child_result = _ensure_doc_tree_under_parent( + tenant_access_token, + space_id=space_id, + parent_node_token=child_token, + doc_items=children, + current_depth=current_depth + 1, + max_depth=max_depth, + ) + created.extend(child_result.get("created", [])) + existing.extend(child_result.get("existing", [])) + failed.extend(child_result.get("failed", [])) + max_depth_reached = max_depth_reached or bool(child_result.get("max_depth_reached")) + + return { + "created": created, + "existing": existing, + "failed": failed, + "max_depth_reached": max_depth_reached, + } + + +def _resolve_doc_token( + tenant_access_token: str, + node: Dict[str, Any], +) -> Optional[str]: + obj_token = node.get("obj_token") + if isinstance(obj_token, str) and obj_token: + return obj_token + + node_token = node.get("node_token") + if not isinstance(node_token, str) or not node_token: + return None + + try: + detail = get_wiki_node_info_by_token(tenant_access_token, node_token) + except Exception: + return None + + detail_obj_token = detail.get("obj_token") + if isinstance(detail_obj_token, str) and detail_obj_token: + return detail_obj_token + return None + + +def _resolve_fst_editor_parent_node( + tenant_access_token: str, + *, + fst_editor_token: Optional[str], + space_id: Optional[str], +) -> Dict[str, Any]: + if fst_editor_token: + try: + return get_wiki_node_info_by_token(tenant_access_token, fst_editor_token) + except Exception: + pass + + if space_id: + root_nodes = list_wiki_child_nodes( + tenant_access_token, + space_id=space_id, + parent_node_token=None, + ) + target = next( + ( + node + for node in root_nodes + if isinstance(node, dict) + and _extract_node_title(node).strip().lower() == FST_EDITOR_DOC_TITLE.lower() + and str(node.get("obj_type") or "") in {"docx", "doc"} + ), + None, + ) + if isinstance(target, dict): + return target + + raise RuntimeError("cannot resolve fst_editor parent doc node by token/title") + + +def ensure_child_docs_for_first_level_names( + tenant_access_token: str, + content: Any, + *, + fst_editor_token: Optional[str] = None, + bitable_app_token: Optional[str] = None, + space_id: Optional[str] = None, +) -> Dict[str, Any]: + extraction_debug = debug_extract_first_level_names(content) + parent_node = _resolve_fst_editor_parent_node( + tenant_access_token, + fst_editor_token=fst_editor_token, + space_id=space_id, + ) + space_id = str(parent_node.get("space_id") or "") + parent_node_token = str(parent_node.get("node_token") or "") + if not space_id or not parent_node_token: + raise RuntimeError("fst_editor parent node missing space_id or node_token") + + # Only create level-1 documents. + target_names = _extract_first_level_names(content) + rows_by_first_level = _build_rows_by_first_level(content) + if not target_names and rows_by_first_level: + target_names = list(rows_by_first_level.keys()) + + # Keep deterministic order while allowing fallback names from grouped rows. + seen_names: Set[str] = set() + merged_target_names: List[str] = [] + for name in target_names + list(rows_by_first_level.keys()): + if not isinstance(name, str): + continue + clean_name = name.strip() + if not clean_name or clean_name in seen_names: + continue + seen_names.add(clean_name) + merged_target_names.append(clean_name) + target_names = merged_target_names + + doc_structure = [{"title": title, "children": []} for title in target_names] + + tree_sync_result = _ensure_doc_tree_under_parent( + tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + doc_items=doc_structure, + current_depth=0, + max_depth=DOC_TREE_MAX_DEPTH, + ) + + existing_children = list_wiki_child_nodes( + tenant_access_token, + space_id=space_id, + parent_node_token=parent_node_token, + ) + # Keep creation scope to direct children only; avoid deep traversal blocking main sync flow. + existing_descendants = existing_children + existing_title_to_node = { + _extract_node_title(node).strip(): node + for node in existing_children + if isinstance(node, dict) and _extract_node_title(node).strip() + } + + synced_title_to_doc_token = { + str(item.get("title")).strip(): str(item.get("obj_token") or "") + for item in (tree_sync_result.get("created", []) + tree_sync_result.get("existing", [])) + if isinstance(item, dict) + and isinstance(item.get("title"), str) + and str(item.get("title")).strip() + } + + created_docs: List[Dict[str, Any]] = [ + item for item in tree_sync_result.get("created", []) if item.get("depth") == 0 + ] + existing_docs: List[Dict[str, Any]] = [ + item for item in tree_sync_result.get("existing", []) if item.get("depth") == 0 + ] + insertion_results: List[Dict[str, Any]] = [] + missing_names: List[str] = [] + top_level_created_names = {item.get("title") for item in created_docs if isinstance(item.get("title"), str)} + + for name in target_names: + existing = existing_title_to_node.get(name) + if existing: + target_node = existing + document_id = _resolve_doc_token(tenant_access_token, target_node) + if (not isinstance(document_id, str) or not document_id) and synced_title_to_doc_token.get(name): + document_id = synced_title_to_doc_token.get(name) + + if isinstance(document_id, str) and document_id: + try: + top_bitable_result = ensure_bitable_block_for_document( + tenant_access_token, + document_id=document_id, + ) + write_result = sync_document_bitable_rows( + tenant_access_token, + document_id=document_id, + rows=rows_by_first_level.get(name, []), + bitable_app_token=bitable_app_token, + ) + insertion_results.append({ + "title": name, + "document_id": document_id, + "inserted": True, + "mode": top_bitable_result.get("mode"), + "row_count": len(rows_by_first_level.get(name, [])), + "bitable_write_result": write_result, + "source": "created" if name in top_level_created_names else "existing", + }) + except Exception as exc: + insertion_results.append({ + "title": name, + "document_id": document_id, + "inserted": False, + "source": "created" if name in top_level_created_names else "existing", + "error": str(exc), + }) + else: + insertion_results.append({ + "title": name, + "inserted": False, + "source": "created" if name in top_level_created_names else "existing", + "error": "document_id_missing", + }) + continue + + missing_names.append(name) + + return { + "fst_editor": { + "title": _extract_node_title(parent_node), + "space_id": space_id, + "node_token": parent_node_token, + "obj_type": parent_node.get("obj_type"), + "obj_token": parent_node.get("obj_token"), + }, + "target_names": target_names, + "rows_by_first_level_count": { + key: len(value) + for key, value in rows_by_first_level.items() + }, + "extraction_debug": extraction_debug, + "existing_child_count": len(existing_children), + "existing_descendant_count": len(existing_descendants), + "existing_docs": existing_docs, + "created_docs": created_docs, + "tree_sync_result": tree_sync_result, + "insertion_results": insertion_results, + "missing_names": missing_names, + } diff --git a/fst_data_pipeline/apps/root_db_api/src/core/models.py b/fst_data_pipeline/apps/root_db_api/src/core/models.py new file mode 100644 index 0000000..585cb12 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/models.py @@ -0,0 +1,514 @@ +from sqlalchemy import ( + Column, + Integer, + String, + Boolean, + ForeignKey, + TIMESTAMP, + BigInteger, + Text, + SmallInteger, + UniqueConstraint, + Index, + JSON, +) +from sqlalchemy.dialects.postgresql import JSONB, ARRAY +from sqlalchemy.orm import declarative_base +from sqlalchemy.orm import relationship, backref +from sqlalchemy.sql import func + +Base = declarative_base() + +# ============================================================ +# ① 基础准备 +# ============================================================ + + +class Project(Base): + __tablename__ = "project" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, unique=True) + update_time = Column( + TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now(), + comment="last update time", + ) + + bags = relationship( + "BagList", back_populates="project", cascade="all, delete-orphan" + ) + + +# ============================================================ +# ② 字典 & 主数据(无外部依赖) +# ============================================================ + + +class DriveMode(Base): + __tablename__ = "drive_mode" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + type = Column(String(100), nullable=False) + sub_type = Column(String(100)) + reserved_json = Column(JSONB) + comment = Column(Text) + + __table_args__ = (UniqueConstraint("type", "sub_type"),) + + +class TopicList(Base): + __tablename__ = "topic_list" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, unique=True) + type = Column(String(255)) + key_data = Column(Text) + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + reserved_json = Column(JSONB) + + +class ReservedTagList(Base): + __tablename__ = "reserved_tag_list" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + type = Column(String(50)) + creator = Column(String(255)) + comments = Column(Text) + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + is_deleted = Column(Boolean, default=False) + reserved_json = Column(JSONB) + + __table_args__ = ( + Index( + "uq_reserved_tag_list_name_not_deleted", + "name", + unique=True, + postgresql_where=(is_deleted.is_(False)), + ), + ) + + +class GTMeta(Base): + __tablename__ = "gt_meta" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + type = Column(String(100), nullable=False) + path = Column(Text, nullable=False) + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + reserved_json = Column(JSON) + comment = Column(Text) + + # 复合唯一约束 + __table_args__ = (UniqueConstraint("name", "type", name="uq_gt_meta_name_type"),) + + +class VersionList(Base): + __tablename__ = "version_list" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + version = Column(String(255), nullable=False, unique=True) + type = Column(String(50)) + description = Column(Text) + release_date = Column(TIMESTAMP(timezone=True)) + created_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + status = Column(String(50), nullable=False, default="available") + feishu_sync_status = Column(String(50), nullable=False, default="unsynced") + reserved_json = Column(JSONB) + + +# ============================================================ +# ③ 核心业务表(依赖 ①②) +# ============================================================ + + +class BagList(Base): + __tablename__ = "bag_list" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, unique=True) + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + project_id = Column(BigInteger, ForeignKey("project.id", ondelete="CASCADE")) + tile_id = Column(String(255)) + is_decoded = Column(Boolean, default=False) + is_deleted = Column(Boolean, default=False) + od_annotated = Column(Integer) + ld_annotated = Column(Integer) + fst_indexed = Column(Boolean, default=False) + is_active_data = Column(Boolean, default=True) + reserved_str = Column(String(255)) + reserved_json = Column(JSONB) + drive_mode = Column(SmallInteger, ForeignKey("drive_mode.id", ondelete="SET NULL")) + sw_version = Column(String(255)) + hw_version = Column(String(255)) + + project = relationship("Project", back_populates="bags") + lifecycle = relationship( + "BagLifecycle", + back_populates="bag", + uselist=False, + cascade="all, delete-orphan", + ) + secondary_pangu = relationship( + "SecondaryPangu", + back_populates="bag", + uselist=False, + cascade="all, delete-orphan", + ) + secondary_minerva = relationship( + "SecondaryMinerva", + back_populates="bag", + uselist=False, + cascade="all, delete-orphan", + ) + + +class BagLifecycle(Base): + __tablename__ = "bag_lifecycle" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + bag_name = Column( + String(255), + ForeignKey("bag_list.name", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + collect_time = Column(TIMESTAMP(timezone=True)) + clone2dev_time = Column(TIMESTAMP(timezone=True)) + decode_time = Column(TIMESTAMP(timezone=True)) + mining_time = Column(TIMESTAMP(timezone=True)) + auto_annotate_time = Column(TIMESTAMP(timezone=True)) + manual_annotate_time = Column(TIMESTAMP(timezone=True)) + fst_index_time = Column(TIMESTAMP(timezone=True)) + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + + bag = relationship("BagList", back_populates="lifecycle") + + +class MainPangu(Base): + __tablename__ = "main_pangu" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, unique=True) + vehicle = Column(String(255)) + datetime = Column(TIMESTAMP(timezone=True)) + bag_path = Column(Text) + data_path = Column(Text) + reserved_str = Column(String(255)) + reserved_json = Column(JSONB) + + +class JoinedPangu(Base): + __tablename__ = "joined_pangu" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, unique=True) + data_path = Column(Text) + reserved_str = Column(String(255)) + reserved_json = Column(JSONB) + + +class JoinedBags(Base): + __tablename__ = "joined_bags" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + parent_id = Column(BigInteger, nullable=False) + child_id = Column(BigInteger, nullable=False, unique=True) + reserved_str = Column(String(255)) + reserved_json = Column(JSONB) + + +class MainMinerva(Base): + __tablename__ = "main_minerva" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + session_id = Column(String(255), unique=True) + start_ts = Column(TIMESTAMP(timezone=True)) + end_ts = Column(TIMESTAMP(timezone=True)) + length = Column(Integer) + datetime = Column(TIMESTAMP(timezone=True)) + vin = Column(String(255)) + platform = Column(String(255)) + mapped = Column(Boolean, default=False) + path = Column(Text) + converted_path = Column(Text) + gt_path = Column(Text) + reserved_json = Column(JSONB) + + +# ============================================================ +# ④ 子表 & 关联表(依赖 bag_list.id) +# ============================================================ + + +class SecondaryPangu(Base): + __tablename__ = "secondary_pangu" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + bag_id = Column( + BigInteger, + ForeignKey("bag_list.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + lidar_gt_pandar128 = Column(Text) + object_lidar_gt_pandar128_manual = Column(Text) + lidar_fd_multi_scan_raw = Column(Text) + camera_fisheye_left = Column(Text) + camera_fisheye_right = Column(Text) + camera_front_wide = Column(Text) + raw_gps = Column(Text) + raw_imu = Column(Text) + ego_motion = Column(Text) + vehicle_wheel = Column(Text) + calibration = Column(Text) + sdmap = Column(Text) + reserved_json = Column(JSONB) + + bag = relationship("BagList", back_populates="secondary_pangu") + + +class SecondaryMinerva(Base): + __tablename__ = "secondary_minerva" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + bag_id = Column( + BigInteger, + ForeignKey("bag_list.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + lidar_gt_top_p128 = Column(Text) + lidar_parking_gt_front_p128 = Column(Text) + lidar_parking_gt_left_p128 = Column(Text) + lidar_parking_gt_right_p128 = Column(Text) + lidar_parking_gt_rear_p128 = Column(Text) + camera_fisheye_left_200fov = Column(Text) + camera_fisheye_right_200fov = Column(Text) + camera_fisheye_rear_200fov = Column(Text) + camera_fisheye_front_200fov = Column(Text) + camera_front_wide_120fov = Column(Text) + camera_front_tele_30fov = Column(Text) + camera_rear_right_70fov = Column(Text) + camera_rear_left_70fov = Column(Text) + raw_gps = Column(Text) + raw_imu = Column(Text) + ego_motion = Column(Text) + calibration = Column(Text) + rig = Column(Text) + fst_new = Column(Text) + fst_old = Column(Text) + comments = Column(Text) + reserved_json = Column(JSONB) + + bag = relationship("BagList", back_populates="secondary_minerva") + + +class FST(Base): + __tablename__ = "fst" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + parent_id = Column(BigInteger, ForeignKey("fst.id", ondelete="CASCADE")) + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + reserved_json = Column(JSONB) + bag_sum = Column(Integer, default=0) + is_delete = Column(SmallInteger, default=0) + + parent = relationship( + "FST", + remote_side=[id], + backref=backref("children", cascade="all, delete-orphan"), + ) + + +class GeometryInfo(Base): + __tablename__ = "geometry_info" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + rosbag_name = Column(String(255), nullable=False) + gnss_downsampled_points = Column(ARRAY(String)) # 或 geoalchemy2 Geometry + update_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + is_overlapped = Column(Boolean, default=False) + + +class BagTopic(Base): + __tablename__ = "bag_topic" + __table_args__ = (UniqueConstraint("bag_id", "topic_id"),) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + bag_id = Column( + BigInteger, ForeignKey("bag_list.id", ondelete="CASCADE"), nullable=False + ) + topic_id = Column( + BigInteger, ForeignKey("topic_list.id", ondelete="CASCADE"), nullable=False + ) + + bag = relationship("BagList") + topic = relationship("TopicList") + + +class BagReservedTag(Base): + __tablename__ = "bag_reserved_tag" + __table_args__ = (UniqueConstraint("bag_id", "tag_id"),) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + bag_id = Column( + BigInteger, + ForeignKey("bag_list.id", ondelete="CASCADE"), + nullable=False, + ) + tag_id = Column( + BigInteger, + ForeignKey("reserved_tag_list.id", ondelete="CASCADE"), + nullable=False, + ) + + bag = relationship("BagList") + tag = relationship("ReservedTagList") + + +class BagGT(Base): + __tablename__ = "bag_gt" + __table_args__ = (UniqueConstraint("gt_id", "bag_id"),) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + gt_id = Column( + BigInteger, + ForeignKey("gt_meta.id", ondelete="CASCADE"), + nullable=False, + ) + bag_id = Column( + BigInteger, + ForeignKey("bag_list.id", ondelete="CASCADE"), + nullable=False, + ) + comment = Column(Text) + + +class FSTBag(Base): + __tablename__ = "fst_bag" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + bag_id = Column( + BigInteger, + ForeignKey("bag_list.id", ondelete="CASCADE"), + nullable=False, + ) + fst_node_id = Column( + BigInteger, + ForeignKey("fst.id", ondelete="CASCADE"), + nullable=False, + ) + fst_node_level = Column(Integer, nullable=False) + event_start_time = Column(Integer) + event_end_time = Column(Integer) + img_url = Column(String(255)) + video_url = Column(String(255)) + comments = Column(String(255)) + + bag = relationship("BagList", backref="fst_links") + fst_node = relationship("FST", backref="bag_links") + + +# ============================================================ +# Recompute 结果存储表 +# ============================================================ + + +class RecomputeResult(Base): + __tablename__ = "recompute_result" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + bag_id = Column( + BigInteger, + ForeignKey("bag_list.id", ondelete="CASCADE"), + nullable=False, + comment="Bag ID", + ) + recompute_version = Column(String(100), nullable=False, comment="Recompute版本") + result_type = Column( + String(50), nullable=False, comment="结果类型 (如: perception, planning, etc)" + ) + storage_path = Column(String(500), nullable=False, comment="结果存储路径") + status = Column( + String(50), + nullable=False, + default="available", + comment="结果状态: available, archived, deleted", + ) + created_time = Column( + TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now(), + comment="Recompute完成时间", + ) + updated_time = Column( + TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + comment="更新时间", + ) + reserved_json = Column(JSONB, comment="预留JSON字段") + + # 与BagList的关系 + bag = relationship("BagList", backref="recompute_results") + + # 索引和约束 + __table_args__ = ( + # 同一个bag的同一版本同一类型的recompute结果应该是唯一的 + UniqueConstraint( + "bag_id", "recompute_version", "result_type", name="uq_recompute_result" + ), + Index("idx_recompute_result_version", "recompute_version"), + Index("idx_recompute_result_type", "result_type"), + Index("idx_recompute_result_status", "status"), + Index("idx_recompute_result_created_time", "created_time"), + Index("idx_recompute_result_storage_path", "storage_path"), + ) + + +class FstStash(Base): + __tablename__ = "fst_stash" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + fst_versions = Column( + String(255), + ForeignKey("version_list.version", ondelete="CASCADE"), + nullable=False, + ) + content = Column(JSONB, nullable=True) + created_time = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + updated_time = Column( + TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + version_rel = relationship("VersionList", backref=backref("fst_stashes", cascade="all, delete-orphan")) diff --git a/fst_data_pipeline/apps/root_db_api/src/core/service.py b/fst_data_pipeline/apps/root_db_api/src/core/service.py new file mode 100644 index 0000000..3a923c7 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/service.py @@ -0,0 +1,2530 @@ +import logging +import copy +from datetime import date, datetime, time, timedelta +from typing import Dict, Any, Set, List, Optional, Tuple, TYPE_CHECKING +import re + +from pydantic import BaseModel +from sqlalchemy import func, distinct, tuple_, update, text, and_, exists +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, aliased +from sqlalchemy.orm import joinedload +from sqlalchemy.sql.expression import select + +from fst_data_pipeline.apps.root_db_api.src.core import models +from fst_data_pipeline.apps.root_db_api.src.core.models import ( + MainPangu, + MainMinerva, + BagLifecycle, + GeometryInfo, + FST, + TopicList, + ReservedTagList, + SecondaryMinerva, + JoinedPangu, + JoinedBags, + GTMeta, + BagGT, +) +from fst_data_pipeline.apps.root_db_api.src.core.models import ( + Project, + BagList, + BagTopic, + BagReservedTag, + FSTBag, + SecondaryPangu, + RecomputeResult, + VersionList, +) +from fst_data_pipeline.apps.root_db_api.src.db.cache import memory_cache as cache +from fst_data_pipeline.apps.root_db_api.src.db.connection import SessionLocal + +if TYPE_CHECKING: + from fst_data_pipeline.apps.root_db_api.src.core.models import FstStash + + +# Project services +def get_projects(db: Session) -> list[dict]: + return [{"id": p.id, "name": p.name} for p in db.query(Project).all()] + + +# Bag services +def get_bags(db: Session): + return db.query(BagList).options(joinedload(BagList.project)).all() + + +# Geometry Info services +def get_geometry_info(db: Session): + return db.query(GeometryInfo).all() + + +# ---------- private helpers ---------- +def _topic_to_dict(topic: TopicList) -> Dict[str, Any]: + return {"name": topic.name, "type": topic.type} + + +# ---------- service functions ---------- +def get_all_topics(db: Session) -> List[Dict[str, Any]]: + """Return all topics as plain dicts.""" + return [_topic_to_dict(t) for t in db.query(TopicList).all()] + + +def get_bags_by_topic_name(db: Session, topic_name: str) -> List[Dict[str, Any]]: + """Return bags whose topic name matches (case-insensitive prefix).""" + bags = ( + db.query(BagList) + .join(BagTopic, BagList.id == BagTopic.bag_id) + .join(TopicList, BagTopic.topic_id == TopicList.id) + .filter(TopicList.name.ilike(f"{topic_name}%")) + .all() + ) + return [_bag_to_dict(b) for b in bags] + + +def _tag_to_dict(tag: ReservedTagList) -> Dict[str, Any]: + return {"name": tag.name, "type": tag.type, "creator": str(tag.creator or "")} + + +def _bag_to_dict(bag: BagList) -> Dict[str, Any]: + return {"name": bag.name} + + +def get_tags(db: Session) -> List[Dict[str, Any]]: + """Return all tags as plain dicts.""" + return [_tag_to_dict(t) for t in db.query(ReservedTagList).all()] + + +def get_tags_by_creator(db: Session, creator: str) -> List[Dict[str, Any]]: + """Return tags whose creator matches (case-insensitive) the given string.""" + tags = ( + db.query(ReservedTagList) + .filter(ReservedTagList.creator.ilike(f"%{creator}%")) + .all() + ) + return [_tag_to_dict(t) for t in tags] + + +def get_bags_by_tag_name(db: Session, tag_name: str) -> List[Dict[str, Any]]: + """Return bags associated with the specified tag name.""" + bags = ( + db.query(BagList) + .join(BagReservedTag, BagList.id == BagReservedTag.bag_id) + .join(ReservedTagList, BagReservedTag.tag_id == ReservedTagList.id) + .filter(ReservedTagList.name == tag_name) + .all() + ) + return [_bag_to_dict(b) for b in bags] + + +def query_all_joined_bags(db: Session) -> Dict[str, List[str]]: + """ + 返回所有 parent 及其对应的 children 名称列表 + """ + # 1. 所有 parent_id + all_parent_ids = db.query(JoinedBags.parent_id).distinct().scalar_subquery() + + # 2. 一次性拿到 parent_id -> children_name + links = ( + db.query(JoinedBags.parent_id, BagList.name.label("child_name")) + .join(BagList, BagList.id == JoinedBags.child_id) + .filter(JoinedBags.parent_id.in_(all_parent_ids)) + .order_by(JoinedBags.parent_id, BagList.id) + .all() + ) + + parent_id2children: Dict[int, List[str]] = {} + for p_id, c_name in links: + parent_id2children.setdefault(p_id, []).append(c_name) + + # 3. 把 parent_id 换成名称 + parent_ids = list(parent_id2children.keys()) + id2name = { + r[0]: r[1] # r is a tuple (id, name) when querying specific columns + for r in db.query(JoinedPangu.id, JoinedPangu.name) + .filter(JoinedPangu.id.in_(parent_ids)) + .all() + } + + return { + id2name[p_id]: children + for p_id, children in parent_id2children.items() + if p_id in id2name + } + + +def query_existed_joined_bag(db: Session, bag_names: List[str]) -> Dict[str, List[str]]: + if not bag_names: + return {} + + bags = db.query(BagList).filter(BagList.name.in_(bag_names)).all() + name2id = {b.name: b.id for b in bags} # name -> id + ids = list(name2id.values()) + + child_rows = ( + db.query(JoinedBags.parent_id, JoinedBags.child_id) + .filter(JoinedBags.child_id.in_(ids)) + .all() + ) + parent_ids_from_child = {r.parent_id for r in child_rows} + + parent_rows = ( + db.query(JoinedPangu.id, JoinedPangu.name) + .filter(JoinedPangu.name.in_(bag_names)) + .all() + ) + name2id_parent = {r.name: r.id for r in parent_rows} + parent_ids_from_name = set(name2id_parent.values()) + + all_parent_ids = parent_ids_from_child | parent_ids_from_name + + links = ( + db.query(JoinedBags.parent_id, BagList.name.label("child_name")) + .join(BagList, BagList.id == JoinedBags.child_id) + .filter(JoinedBags.parent_id.in_(all_parent_ids)) + .order_by(JoinedBags.parent_id, BagList.id) # 保证顺序 & 去重 + .all() + ) + + parent_id2children = {} + for p_id, c_name in links: + parent_id2children.setdefault(p_id, []).append(c_name) + + parent_ids = list(parent_id2children.keys()) + parent_names = ( + db.query(JoinedPangu.id, JoinedPangu.name) + .filter(JoinedPangu.id.in_(parent_ids)) + .all() + ) + id2parent_name = {r.id: r.name for r in parent_names} + + return { + id2parent_name[p_id]: children for p_id, children in parent_id2children.items() + } + + +def _build_parent_name(db: Session, bag_names: List[str]) -> str: + """按规则生成父包名:{一致前缀}_{最早HHMMSS}-{最晚HHMMSS}.bag""" + # 1. 一致前缀 + prefix = bag_names[0] + for name in bag_names[1:]: + while not name.startswith(prefix): + prefix = prefix[:-1] + if not prefix: + break + prefix = prefix.rstrip("_") + + # 2. 取 main_pangu 时间 + rows = db.query(MainPangu.datetime).filter(MainPangu.name.in_(bag_names)).all() + if len(rows) != len(bag_names): + raise ValueError("部分子包在 main_pangu 中缺失时间") + times = [r.datetime for r in rows] + earliest = min(times).strftime("%H%M%S") + latest = max(times).strftime("%H%M%S") + + # 3. 拼接 + if prefix: + return f"{prefix}_{earliest}-{latest}.bag" + return f"{earliest}-{latest}.bag" + + +def merge_closed_bags(db: Session, bag_names: List[str]) -> Dict[str, Any]: + if not bag_names: + raise ValueError("bag_names 不能为空") + + # ---- 1. 存在性校验 ---- # + sub_records = ( + db.query(BagList.id, BagList.name).filter(BagList.name.in_(bag_names)).all() + ) + if len(sub_records) != len(bag_names): + raise ValueError("部分子包在 bag_list 不存在") + + main_cnt = ( + db.query(func.count(MainPangu.id)) + .filter(MainPangu.name.in_(bag_names)) + .scalar() + ) + if main_cnt != len(bag_names): + raise ValueError("部分子包在 main_pangu 不存在") + + # ---- 2. 生成合并包名(新规则) ---- # + parent_name = _build_parent_name(db, bag_names) + if db.query(JoinedPangu).filter_by(name=parent_name).first(): + raise ValueError(f"合并包名 {parent_name} 已存在") + + # ---- 3. 写 joined_pangu(带子包列表) ---- # + joined = JoinedPangu( + name=parent_name, + reserved_json={"child_names": bag_names}, # 保留列表 + ) + db.add(joined) + db.flush() + joined_id = joined.id + + # ---- 4. 写 bag_list(合并包) ---- # + parent_bag = BagList(name=parent_name, is_active_data=True) + db.add(parent_bag) + db.flush() + + # ---- 5. 写 joined_bags 映射 ---- # + for child in sub_records: + db.add(JoinedBags(parent_id=joined_id, child_id=child.id)) + + # ---- 6. 回写子包:仅 is_active_data=False ---- # + db.query(BagList).filter(BagList.name.in_(bag_names)).update( + {"is_active_data": False}, synchronize_session=False + ) + + db.commit() + return {"joined_id": joined_id, "joined_name": parent_name} + + +def delete_existed_joined_bag( + db: Session, bag_names: List[str] +) -> Dict[str, List[str]]: + if not bag_names: + raise ValueError("bag_names 不能为空") + bag_names = list(set(bag_names)) + + # 1. 归一化到父包 id 集合(逻辑同旧代码) + parent_ids = set() + name2joined_id = { + r.name: r.id + for r in db.query(JoinedPangu.id, JoinedPangu.name) + .filter(JoinedPangu.name.in_(bag_names)) + .all() + } + parent_ids.update(name2joined_id.values()) + + child2parent = ( + db.query(JoinedBags.parent_id, JoinedBags.child_id) + .join(BagList, BagList.id == JoinedBags.child_id) + .filter(BagList.name.in_(bag_names)) + .all() + ) + parent_ids.update({r.parent_id for r in child2parent}) + + if not parent_ids: + return {"deleted_parents": []} + + parents = ( + db.query(JoinedPangu.id, JoinedPangu.name) + .filter(JoinedPangu.id.in_(parent_ids)) + .all() + ) + parent_id2name = {p.id: p.name for p in parents} + deleted_names = list(parent_id2name.values()) + + # 2. 收集所有子包 id(用于后面更新) + child_ids = [ + r.child_id + for r in db.query(JoinedBags.child_id) + .filter(JoinedBags.parent_id.in_(parent_ids)) + .distinct() + .all() + ] + + try: + # 3. 删映射 + db.query(JoinedBags).filter(JoinedBags.parent_id.in_(parent_ids)).delete( + synchronize_session=False + ) + + # 4. 删父包 + db.query(JoinedPangu).filter(JoinedPangu.id.in_(parent_ids)).delete( + synchronize_session=False + ) + + # 5. 删 bag_list 里的合并包 + db.query(BagList).filter(BagList.name.in_(deleted_names)).delete( + synchronize_session=False + ) + + # 6. 重置子包 is_active_data=True(reserved_json 不动) + if child_ids: + db.query(BagList).filter(BagList.id.in_(child_ids)).update( + {"is_active_data": True}, synchronize_session=False + ) + + db.commit() + except Exception: + db.rollback() + raise + + return {"deleted_parents": deleted_names} + + +def query_bags_by_tags( + db: Session, + tag_list: List[str], + op: str = "and", +) -> List[BagList]: + """ + 根据多个标签名查询bag list + :param tag_list: 标签名列表 + :param op: 'and' 交集 | 'or' 并集 + :return: BagList 对象列表 + """ + if not tag_list: + return [] + + # 1. 拿到所有有效 tag id(缺失的已在上游自动插入) + tag_ids = [ + row[0] + for row in db.query(ReservedTagList.id) + .filter( + ReservedTagList.name.in_(tag_list), ReservedTagList.is_deleted.is_(False) + ) + .all() + ] + if not tag_ids: + return [] + + query = db.query(BagList.name) + + if op == "and": + # 交集:bag必须关联到所有 tag + query = ( + query.join(BagReservedTag) + .filter(BagReservedTag.tag_id.in_(tag_ids)) + .group_by(BagList.id) + .having(func.count(func.distinct(BagReservedTag.tag_id)) == len(tag_ids)) + ) + else: # or + # 并集:bag关联到任一 tag + query = ( + query.join(BagReservedTag) + .filter(BagReservedTag.tag_id.in_(tag_ids)) + .distinct() + ) + + return [str(row[0]) for row in query.all()] + + +# Bags search service +def get_bags_by_conditions( + db: Session, name: str = None, project_id: int = None, tile_id: str = None +): + query = db.query(BagList) + if name: + query = query.filter(BagList.name.ilike(f"%{name}%")) # 使用模糊匹配 + if project_id is not None: + query = query.filter(BagList.project_id == project_id) + if tile_id: + query = query.filter(BagList.tile_id.ilike(f"%{tile_id}%")) + return query.all() + + +def get_main_pangu_by_conditions( + db: Session, + *, + start_time: date | None, + end_time: date | None, + vehicle: str | None, + keyword: str | None, + topics: list[str] | None = None, + fst: str | None = None, + limit: int = 1000, +) -> list["MainPangu"]: + topics = [t.strip() for t in (topics or []) if t and t.strip()] + topics = list(dict.fromkeys(topics)) + + fst = fst.strip() if fst and fst.strip() else None + + q = db.query(MainPangu) + + if start_time: + q = q.filter(func.date(MainPangu.datetime) >= start_time) + if end_time: + q = q.filter(func.date(MainPangu.datetime) <= end_time) + + if vehicle: + q = q.filter(MainPangu.vehicle.ilike(f"{vehicle}%")) + + if keyword: + q = q.filter(MainPangu.name.ilike(f"%{keyword}%")) + + q = q.join(BagList, BagList.name == MainPangu.name) + + for tp in topics: + q = q.filter( + exists().where( + and_( + BagTopic.bag_id == BagList.id, + BagTopic.topic_id == TopicList.id, + TopicList.name == tp, + ) + ) + ) + + # 4) fst:精确匹配 fst.name,且该 bag 在 fst_bag 中存在对应关系 + if fst: + q = q.filter( + exists().where( + and_( + FSTBag.bag_id == BagList.id, + FSTBag.fst_node_id == FST.id, + FST.name == fst, + ) + ) + ) + + return q.limit(limit).all() + + +def _normalize_name_list(values: list[str] | None) -> list[str]: + if not values: + return [] + cleaned: list[str] = [] + seen = set() + for v in values: + if not v: + continue + v = v.strip() + if not v or v in seen: + continue + cleaned.append(v) + seen.add(v) + return cleaned + + +def _build_like_pattern(value: str | None) -> str | None: + if not value: + return None + value = value.strip() + if not value: + return None + value = value.replace("*", "%") + if "%" not in value: + value = f"%{value}%" + return value + + +def _none_to_empty_str(value: Any) -> str: + return value if value is not None else "" + + +def _dt_to_string(value: Any) -> str: + if value is None: + return "" + try: + return value.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return "" + + +def _compile_sql(db: Session, query) -> str: + try: + bind = db.get_bind() + dialect = bind.dialect if bind is not None else None + if dialect is not None: + return str( + query.statement.compile( + dialect=dialect, compile_kwargs={"literal_binds": True} + ) + ) + return str(query.statement.compile(compile_kwargs={"literal_binds": True})) + except Exception: + return str(query.statement) + + +def _load_fst_nodes_with_ancestors( + db: Session, fst_ids: set[int] +) -> dict[int, dict[str, Any]]: + if not fst_ids: + return {} + nodes: dict[int, dict[str, Any]] = {} + pending = set(fst_ids) + while pending: + rows = ( + db.query(FST.id, FST.name, FST.parent_id) + .filter(FST.id.in_(pending)) + .filter(FST.is_delete == 0) + .all() + ) + pending = set() + for r in rows: + if r.id in nodes: + continue + nodes[r.id] = {"id": r.id, "name": r.name, "parent_id": r.parent_id} + if r.parent_id and r.parent_id not in nodes: + pending.add(r.parent_id) + return nodes + + +def search_aggregate_bag_ids( + db: Session, + *, + collect_start: date | None, + collect_end: date | None, + bag_name: str | None, + gt_names: list[str] | None, + fst_name: str | None, + vehicles: list[str] | None, + sw_version: str | None, + hw_version: str | None, + topics: list[str] | None, + tags: list[str] | None, + page: int, + per_page: int, + debug_sql: bool = False, +) -> tuple[list[int], int]: + topics = _normalize_name_list(topics) + tags = _normalize_name_list(tags) + gt_names = _normalize_name_list(gt_names) + fst_name = fst_name.strip() if fst_name and fst_name.strip() else None + bag_pattern = _build_like_pattern(bag_name) + vehicles = _normalize_name_list(vehicles) + + sw_pattern = _build_like_pattern(sw_version) + hw_pattern = _build_like_pattern(hw_version) + + q = db.query(BagList.id).join(MainPangu, MainPangu.name == BagList.name) + + if collect_start: + start_dt = datetime.combine(collect_start, time.min) + q = q.filter(MainPangu.datetime >= start_dt) + if collect_end: + end_dt = datetime.combine(collect_end + timedelta(days=1), time.min) + q = q.filter(MainPangu.datetime < end_dt) + + if bag_pattern: + q = q.filter(BagList.name.ilike(bag_pattern)) + + if vehicles: + q = q.filter(MainPangu.vehicle.in_(vehicles)) + + if sw_pattern: + q = q.filter(BagList.sw_version.ilike(sw_pattern)) + if hw_pattern: + q = q.filter(BagList.hw_version.ilike(hw_pattern)) + + if topics: + q = q.filter( + exists().where( + and_( + BagTopic.bag_id == BagList.id, + BagTopic.topic_id == TopicList.id, + TopicList.name.in_(topics), + ) + ) + ) + + if tags: + q = q.filter( + exists().where( + and_( + BagReservedTag.bag_id == BagList.id, + BagReservedTag.tag_id == ReservedTagList.id, + ReservedTagList.name.in_(tags), + ReservedTagList.is_deleted.is_(False), + ) + ) + ) + + if gt_names: + q = q.filter( + exists().where( + and_( + BagGT.bag_id == BagList.id, + BagGT.gt_id == GTMeta.id, + GTMeta.name.in_(gt_names), + ) + ) + ) + + if fst_name: + fst_ids = _fst_ids_by_name(db, fst_name) + if not fst_ids: + return [], 0 + q = q.filter( + exists().where( + and_( + FSTBag.bag_id == BagList.id, + FSTBag.fst_node_id.in_(fst_ids), + ) + ) + ) + + count_q = q.with_entities(func.count()) + if debug_sql: + logging.getLogger(__name__).info( + "aggregate_search count SQL: %s", _compile_sql(db, count_q) + ) + total = count_q.scalar() or 0 + + q = q.order_by(MainPangu.datetime.desc().nullslast()) + + q = q.offset((page - 1) * per_page).limit(per_page) + if debug_sql: + logging.getLogger(__name__).info( + "aggregate_search items SQL: %s", _compile_sql(db, q) + ) + bag_ids = [r[0] for r in q.all()] + return bag_ids, total + + +def get_aggregate_bag_details( + db: Session, bag_ids: list[int] +) -> list[dict[str, Any]]: + if not bag_ids: + return [] + + bag_rows = ( + db.query(BagList.id, BagList.name, BagList.sw_version, BagList.hw_version) + .filter(BagList.id.in_(bag_ids)) + .all() + ) + bag_info = { + r.id: { + "bag_id": r.id, + "bag_name": r.name, + "sw_version": r.sw_version, + "hw_version": r.hw_version, + } + for r in bag_rows + } + + bag_names = [info["bag_name"] for info in bag_info.values()] + + pangu_rows = ( + db.query( + MainPangu.name, + MainPangu.vehicle, + MainPangu.datetime, + MainPangu.bag_path, + MainPangu.data_path, + ) + .filter(MainPangu.name.in_(bag_names)) + .all() + ) + pangu_by_name = { + r.name: { + "vehicle": r.vehicle, + "datetime": r.datetime, + "bag_path": r.bag_path, + "data_path": r.data_path, + } + for r in pangu_rows + } + + lifecycle_rows = ( + db.query(BagLifecycle.bag_name, BagLifecycle.collect_time) + .filter(BagLifecycle.bag_name.in_(bag_names)) + .all() + ) + collect_time_by_name = {r.bag_name: r.collect_time for r in lifecycle_rows} + + gt_rows = ( + db.query( + BagGT.bag_id, + GTMeta.id, + GTMeta.name, + GTMeta.type, + GTMeta.path, + GTMeta.comment, + ) + .join(GTMeta, BagGT.gt_id == GTMeta.id) + .filter(BagGT.bag_id.in_(bag_ids)) + .all() + ) + gt_by_bag: dict[int, list[dict[str, Any]]] = {} + for r in gt_rows: + gt_by_bag.setdefault(r.bag_id, []).append({ + "id": r.id, + "name": _none_to_empty_str(r.name), + "type": _none_to_empty_str(r.type), + "path": _none_to_empty_str(r.path), + "comment": _none_to_empty_str(r.comment), + }) + + topic_rows = ( + db.query(BagTopic.bag_id, TopicList.id, TopicList.name, TopicList.type) + .join(TopicList, BagTopic.topic_id == TopicList.id) + .filter(BagTopic.bag_id.in_(bag_ids)) + .all() + ) + topics_by_bag: dict[int, list[dict[str, Any]]] = {} + for r in topic_rows: + topics_by_bag.setdefault(r.bag_id, []).append({ + "id": r.id, + "name": _none_to_empty_str(r.name), + "type": _none_to_empty_str(r.type), + }) + + tag_rows = ( + db.query( + BagReservedTag.bag_id, + ReservedTagList.id, + ReservedTagList.name, + ReservedTagList.type, + ReservedTagList.creator, + ) + .join(ReservedTagList, BagReservedTag.tag_id == ReservedTagList.id) + .filter( + BagReservedTag.bag_id.in_(bag_ids), + ReservedTagList.is_deleted.is_(False), + ) + .all() + ) + tags_by_bag: dict[int, list[dict[str, Any]]] = {} + for r in tag_rows: + tags_by_bag.setdefault(r.bag_id, []).append({ + "id": r.id, + "name": _none_to_empty_str(r.name), + "type": _none_to_empty_str(r.type), + "creator": _none_to_empty_str(r.creator), + }) + + fst_rows = ( + db.query(FSTBag.bag_id, FSTBag.fst_node_id) + .filter(FSTBag.bag_id.in_(bag_ids)) + .all() + ) + fst_ids = {r.fst_node_id for r in fst_rows} + fst_nodes = _load_fst_nodes_with_ancestors(db, fst_ids) + fst_path_cache: dict[int, list[dict[str, Any]]] = {} + + def _fst_path(fst_id: int) -> list[dict[str, Any]]: + if fst_id in fst_path_cache: + return fst_path_cache[fst_id] + path: list[dict[str, Any]] = [] + current = fst_id + seen = set() + while current and current in fst_nodes and current not in seen: + node = fst_nodes[current] + path.append({"id": node["id"], "name": node["name"]}) + seen.add(current) + current = node["parent_id"] + path.reverse() + fst_path_cache[fst_id] = path + return path + + fst_by_bag: dict[int, list[dict[str, Any]]] = {} + for r in fst_rows: + path = _fst_path(r.fst_node_id) + # 跳过 level0(root),让 level1 对应业务一级节点 + levels = path[1:] if len(path) > 0 else [] + fst_by_bag.setdefault(r.bag_id, []).append({ + "level1": levels[0] if len(levels) > 0 else {}, + "level2": levels[1] if len(levels) > 1 else {}, + "level3": levels[2] if len(levels) > 2 else {}, + "level4": levels[3] if len(levels) > 3 else {}, + }) + + items: list[dict[str, Any]] = [] + for bag_id in bag_ids: + info = bag_info.get(bag_id) + if not info: + continue + name = info["bag_name"] + pangu = pangu_by_name.get(name, {}) + items.append({ + "bag_id": bag_id, + "bag_name": name, + "bag_path": _none_to_empty_str(pangu.get("bag_path")), + "data_path": _none_to_empty_str(pangu.get("data_path")), + "datetime": _dt_to_string(pangu.get("datetime")), + "collect_time": _dt_to_string(collect_time_by_name.get(name)), + "vehicle": _none_to_empty_str(pangu.get("vehicle")), + "sw_version": _none_to_empty_str(info.get("sw_version")), + "hw_version": _none_to_empty_str(info.get("hw_version")), + "mviz_link": "", + "mbviz_link": "", + "fst": fst_by_bag.get(bag_id, []), + "gt": gt_by_bag.get(bag_id, []), + "topics": topics_by_bag.get(bag_id, []), + "tags": tags_by_bag.get(bag_id, []), + }) + + return items + + +def _bag_names_by_topics_all(db: Session, topics: list[str] | None) -> set[str] | None: + if not topics: + return None + topics = [t.strip() for t in topics if t and t.strip()] + if not topics: + return None + + bag_names: set[str] | None = None + + for topic in topics: + rows = ( + db.query(BagList.name) + .select_from(BagList) + .join(BagTopic, BagList.id == BagTopic.bag_id) + .join(TopicList, BagTopic.topic_id == TopicList.id) + .filter(TopicList.name.ilike(f"{topic}%")) + .distinct() + .all() + ) + current = {r[0] for r in rows} + + bag_names = current if bag_names is None else (bag_names & current) + if not bag_names: + return set() + + return bag_names + + +# 由 fst 节点名 → 该节点及所有子孙的 fst_id 集合 +def _fst_ids_by_name(db: Session, name: str | None) -> set[int] | None: + if not name: + return None + sql = text(""" + WITH RECURSIVE tree AS ( + SELECT id FROM fst WHERE name = :name + UNION ALL + SELECT f.id FROM fst f JOIN tree t ON f.parent_id = t.id + ) + SELECT id FROM tree + """).bindparams(name=name) + return {r.id for r in db.execute(sql)} + + +# 由 fst_id 集合 → bag_id 集合 +def _bag_ids_by_fst(db: Session, fst_ids: set[int]) -> set[int]: + rows = db.query(FSTBag.bag_id).filter(FSTBag.fst_node_id.in_(fst_ids)).all() + return {r.bag_id for r in rows} + + +# Main Pangu by name +def get_main_pangu_by_name(db: Session, name: str): + return db.query(MainPangu).filter(MainPangu.name.ilike(f"%{name}%")).all() + + +# Main Minerva by session_id +def get_main_minerva_by_session_id(db: Session, session_id: str): + return ( + db.query(MainMinerva) + .filter(MainMinerva.session_id.ilike(f"%{session_id}%")) + .all() + ) + + +# Bag Lifecycle by bag_name +def get_bag_lifecycle_by_bag_names(db: Session, bag_names): + """根据多个 Bag 名称获取生命周期信息""" + return db.query(BagLifecycle).filter(BagLifecycle.bag_name.in_(bag_names)).all() + + +# Geometry Info by rosbag_name +def get_geometry_info_by_rosbag_name(db: Session, rosbag_name: str): + return ( + db.query(GeometryInfo).filter(GeometryInfo.rosbag_name == rosbag_name).first() + ) + + +# FST by name +def get_fst(db: Session, name: str): + return db.query(FST).filter(FST.name == name, FST.is_delete == 0).first() + + +def get_total_bag_sum(db: Session, root_name: str) -> int: + """ + 计算以 root_name 为根的整棵子树(含根节点)的 bag_sum 总和。 + 仅发一条 SQL(递归 CTE),返回整数。 + """ + # 递归 CTE:subtree 里拿到所有后代 id + subtree_cte = select(FST.id).where(FST.name == root_name).cte(recursive=True) + parent = aliased(subtree_cte) + child = aliased(FST) + subtree_cte = subtree_cte.union_all( + select(child.id).where(child.parent_id == parent.c.id) + ) + + total = ( + db.query(func.coalesce(func.sum(FST.bag_sum), 0)) + .filter(FST.id.in_(select(subtree_cte.c.id))) + .scalar() + ) + return total or 0 + + +def get_fst_by_id(db: Session, id: int): + return db.query(FST).filter(FST.id == id, FST.is_delete == 0).first() + + +# Bag → Pangu +def get_pangu_details_by_bag_name(db: Session, bag_name: str) -> Dict[str, Any]: + main = db.query(MainPangu).filter(MainPangu.name == bag_name).first() + if not main: + return {} + + bag = db.query(BagList).filter(BagList.name == main.name).first() + sec = db.query(SecondaryPangu).filter(SecondaryPangu.bag_id == bag.id).first() + + # 需要排除的键 + drop_keys = { + "_sa_instance_state", + "id", + "bag_id", + "name", + } + + # 合并并剔除 None / 内部字段 + merged = {"bag_name": bag_name} + for obj in (main, bag, sec): + if obj: + merged.update({ + k: v + for k, v in obj.__dict__.items() + if k not in drop_keys and v is not None + }) + + return merged + + +def query_bag_names_by_version( + db: Session, + sw_pattern: Optional[str] = None, + hw_pattern: Optional[str] = None, +) -> List[str]: + """ + 按软件/硬件版本模糊查询 bag name,支持 * 通配(调用前需把 *→%) + 若同时给出 sw 与 hw,则取交集 + """ + q = db.query(models.BagList.name) + + filters = [] + if sw_pattern is not None: + filters.append(models.BagList.sw_version.ilike(sw_pattern)) + if hw_pattern is not None: + filters.append(models.BagList.hw_version.ilike(hw_pattern)) + + if not filters: + return [] + + q = q.filter(and_(*filters)) + return [row[0] for row in q.all()] + + +def get_distinct_versions(db: Session) -> Dict[str, List[str]]: + """Return distinct sw_version/hw_version values from bag_list.""" + sw_rows = ( + db.query(BagList.sw_version) + .filter(BagList.sw_version.isnot(None)) + .filter(BagList.sw_version != "") + .distinct() + .order_by(BagList.sw_version) + .all() + ) + hw_rows = ( + db.query(BagList.hw_version) + .filter(BagList.hw_version.isnot(None)) + .filter(BagList.hw_version != "") + .distinct() + .order_by(BagList.hw_version) + .all() + ) + return { + "sw_versions": [r[0] for r in sw_rows], + "hw_versions": [r[0] for r in hw_rows], + } + + +def get_distinct_vehicles(db: Session) -> Dict[str, List[str]]: + """Return distinct vehicle values from main_pangu.""" + rows = ( + db.query(MainPangu.vehicle) + .filter(MainPangu.vehicle.isnot(None)) + .filter(MainPangu.vehicle != "") + .distinct() + .order_by(MainPangu.vehicle) + .all() + ) + return {"vehicles": [r[0] for r in rows]} + + +# Bag → Minerva +def get_minerva_details_by_bag_name(db: Session, session_id: str) -> Dict[str, Any]: + main = db.query(MainMinerva).filter(MainMinerva.session_id == session_id).first() + if not main: + return {} + + bag = db.query(BagList).filter(BagList.name == main.session_id).first() + sec = db.query(SecondaryMinerva).filter(SecondaryMinerva.bag_id == bag.id).first() + + # 需要排除的键 + drop_keys = { + "_sa_instance_state", + "id", + "bag_id", + "name", + } + + # 合并并剔除 None / 内部字段 + merged = {"bag_name": session_id} + for obj in (main, bag, sec): + if obj: + merged.update({ + k: v + for k, v in obj.__dict__.items() + if k not in drop_keys and v is not None + }) + + return merged + + +# Bag → Topics +def get_topics_by_bag_name(db: Session, bag_name: str): + return ( + db.query(TopicList) + .join(BagTopic) + .join(BagList) + .filter(BagList.name == bag_name) + .all() + ) + + +# Bag → Reserved Tags +def get_tags_by_bag_name(db: Session, bag_name: str): + return ( + db.query(ReservedTagList) + .join(BagReservedTag) + .join(BagList) + .filter(BagList.name == bag_name) + .all() + ) + + +# Bag → FST Nodes +from sqlalchemy.orm import Session +from typing import List, Dict, Any + + +def get_fst_nodes_by_bag_name(db: Session, bag_name: str) -> List[Dict[str, Any]]: + """ + 返回每个 FST 与 bag 对应关系的扁平对象列表: + [ + {"name": "fst1", "start": "...", "end": "..."}, + ... + ] + """ + rows = ( + db.query( + FST.name, + FSTBag.event_start_time.label("start"), + FSTBag.event_end_time.label("end"), + FSTBag.comments.label("comments"), + FSTBag.img_url.label("img_url"), + FSTBag.video_url.label("video_url"), + ) + .select_from(BagList) + .join(FSTBag, FSTBag.bag_id == BagList.id) + .join(FST, FST.id == FSTBag.fst_node_id) + .filter(FST.is_delete == 0) + .filter(BagList.name == bag_name) + .all() + ) + # 把 Row 对象直接转成 dict;None 值可根据需要再过滤 + return [ + { + "name": r.name, + "start": f"{r.start}" + "s", + "end": f"{r.end}" + "s", + "comments": r.comments, + "video_url": r.video_url, + "img_url": r.img_url, + } + for r in rows + ] + + +# Bag → FST Path +def get_fst_path_by_bag_name(db: Session, bag_name: str): + # Here you need to implement the logic to get the path of the FST nodes related to the bag + bag = db.query(BagList).filter(BagList.name == bag_name).first() + if not bag: + return None + # Assuming you have a method to get the FST path + fst_path = [] # Replace with logic to retrieve FST path + current_node = bag.fst_node_id # Assuming you have a fst_node_id in BagList + while current_node: + node = db.query(FST).filter(FST.id == current_node).first() + if node: + fst_path.append({"id": node.id, "name": node.name}) + current_node = node.parent_id # Move to the parent node + else: + break + return fst_path + + +# FST → Bags +def get_bags_by_fst_name(db: Session, name: str): + return ( + db.query(BagList) + .join(FSTBag) + .join(FST) + .filter(FST.name == name, FST.is_delete == 0) + .all() + ) + + +def get_bag_names_linked_to_fst(db): + return ( + db.query(distinct(BagList.name)) + .join(FSTBag, FSTBag.bag_id == BagList.id) + .filter(BagList.is_deleted.is_(False)) + .order_by(BagList.name) + .all() + ) + + +def get_fst_tree_with_bag_details(db: Session) -> Dict[str, Any]: + """ + Return complete FST tree structure with bag details including timing and comments. + Returns tree structure where each FST node contains associated bags with: + - bag_name + - event_start_time + - event_end_time + - comments + """ + from sqlalchemy import text + + # SQL query to get all FST nodes with their associated bags and details + sql = text(""" + SELECT f.id, + f.name, + f.parent_id, + pf.name AS parent_name, + bl.id AS bag_id, + bl.name AS bag_name, + fb.event_start_time, + fb.event_end_time, + fb.comments + FROM fst f + LEFT JOIN fst pf ON pf.id = f.parent_id + LEFT JOIN fst_bag fb ON fb.fst_node_id = f.id + LEFT JOIN bag_list bl ON bl.id = fb.bag_id AND bl.is_deleted = false + WHERE f.is_delete = 0 + ORDER BY f.id, bl.name; + """) + + rows = db.execute(sql).all() + + # Build tree structure in memory + tree: Dict[int, Dict[str, Any]] = {} + for r in rows: + ( + fst_id, + fst_name, + parent_id, + parent_name, + bag_id, + bag_name, + event_start_time, + event_end_time, + comments, + ) = r + + if fst_id not in tree: + tree[fst_id] = { + "id": fst_id, + "name": fst_name, + "parent_id": parent_id, + "parent_name": parent_name, + "bags": [], + "children": {}, + } + + # Add bag details if bag exists + if bag_id: + tree[fst_id]["bags"].append({ + "bag_name": bag_name, + "event_start_time": event_start_time, + "event_end_time": event_end_time, + "comments": comments, + }) + + # Build hierarchical tree structure + roots: Dict[int, Dict[str, Any]] = {} + for fst_id, node in tree.items(): + parent_id = node["parent_id"] + if parent_id is None: + roots[fst_id] = node + else: + if parent_id in tree: + tree[parent_id]["children"][fst_id] = node + else: + # Defensive: if parent not found, treat as root + roots[fst_id] = node + + return roots + + +def get_all_related_bags( + db: Session, bag_name: str, before: int, after: int +) -> List[str]: + target_record = db.query(MainPangu).filter_by(name=bag_name).first() + if not target_record: + return [] + + target_time = target_record.datetime + prefix = bag_name.split("_")[0] + + # 2. 前驱名称(升序) + prev_names = [ + name + for (name,) in db.query(MainPangu.name) + .filter(MainPangu.datetime < target_time, MainPangu.name.like(f"{prefix}%")) + .order_by(MainPangu.datetime.desc()) + .limit(before) + .all() + ][::-1] + + # 3. 后继名称(升序) + next_names = [ + name + for (name,) in db.query(MainPangu.name) + .filter(MainPangu.datetime > target_time, MainPangu.name.like(f"{prefix}%")) + .order_by(MainPangu.datetime.asc()) + .limit(after) + .all() + ] + + # 4. 合并 + return prev_names + [bag_name] + next_names + + +_CACHE_KEY_TREE = "fst:tree:all" +_CACHE_KEY_NODES = "fst:nodes:all" + + +def get_all_fst_nodes(db: Session) -> List[Dict[str, Any]]: + cached_nodes = cache.get(_CACHE_KEY_NODES) + if cached_nodes is not None: + return cached_nodes + + rows = db.query(FST).filter(FST.is_delete == 0).all() + return rows + + +def print_fst_tree() -> List[Dict[str, Any]]: + """ + 无参版本:先读固定 key 的缓存,没有再查库 + """ + cached_tree = cache.get(_CACHE_KEY_TREE) + if cached_tree is not None: + return cached_tree + + db = SessionLocal() + try: + rows = ( + db.query(FST.id, FST.name, FST.parent_id) + .filter(FST.is_delete == 0) + .all() + ) + + nodes: Dict[int, Dict[str, Any]] = {} + for id_str, name, _ in rows: + try: + nodes[int(id_str)] = { + "id": name, + "label": name, + "level": 0, + "children": [], + } + except (ValueError, TypeError): + continue + + roots: List[Dict[str, Any]] = [] + for id_str, _, pid in rows: + try: + nid = int(id_str) + pid_int = int(pid) if str(pid).isdigit() else None + except (ValueError, TypeError): + continue + if pid_int in nodes: + nodes[pid_int]["children"].append(nodes[nid]) + else: + roots.append(nodes[nid]) + + def set_level(node: Dict[str, Any], lv: int): + node["level"] = lv + for ch in node["children"]: + set_level(ch, lv + 1) + + for r in roots: + set_level(r, 0) + + # 3. 写缓存 + cache.set(_CACHE_KEY_TREE, roots, timeout=600) + return roots + finally: + db.close() + + +def get_bags_by_fst_paths(db: Session, name: str) -> Dict[int, Dict[str, Any]]: + """ + 根据 FST 节点名(精确匹配)返回其及所有子孙的树: + { + fst_id: { + "id": fst_id, + "name": fst_name, + "parent_id": parent_id | None, + "parent_name": parent_name | None, + "bags": [{"id": bag_id, "name": bag_name}, ...], + "children": {child_id: {...}, ...} + } + } + """ + if not name: + return {} + + sql = text(""" + WITH RECURSIVE tree AS ( + SELECT id, parent_id, name + FROM fst + WHERE name = :name AND is_delete = 0 + UNION ALL + SELECT f.id, f.parent_id, f.name + FROM fst f + JOIN tree t ON f.parent_id = t.id + WHERE f.is_delete = 0 + ) + SELECT t.id, + t.name, + t.parent_id, + p.name AS parent_name, + bl.id AS bag_id, + bl.name AS bag_name + FROM tree t + LEFT JOIN fst p ON p.id = t.parent_id -- 父节点名字 + LEFT JOIN fst_bag fb ON fb.fst_node_id = t.id + LEFT JOIN bag_list bl ON bl.id = fb.bag_id + ORDER BY t.id, bl.id; + """).bindparams(name=name) + + rows = db.execute(sql).all() + + # 内存建树 + tree: Dict[int, Dict[str, Any]] = {} + for r in rows: + fst_id, fst_name, parent_id, parent_name, bag_id, bag_name = r + if fst_id not in tree: + tree[fst_id] = { + "id": fst_id, + "name": fst_name, + "parent_id": parent_id, + "parent_name": parent_name, + "bags": [], + "children": {}, + } + if bag_id: # 可能无rosbag + tree[fst_id]["bags"].append({"id": bag_id, "name": bag_name}) + + # 挂到父节点形成树 + roots: Dict[int, Dict[str, Any]] = {} + for fst_id, node in tree.items(): + parent_id = node["parent_id"] + if parent_id is None: + roots[fst_id] = node + else: + if parent_id in tree: + tree[parent_id]["children"][fst_id] = node + else: + roots[fst_id] = node # 防御:父未出现 + + return roots + + +class FSTBagItem(BaseModel): + bag_name: str + nodes: List[str] # 顺序给出各级节点,空字符串视为无效 + start_time: int + end_time: int + comments: str = "" + tags: List[str] = [] + fst_node_level: int + + +class FSTBagResult(BaseModel): + bag_name: str + success: bool + reason: str | None = None + + +def _prefetch( + db: Session, + items: List[FSTBagItem], +) -> Tuple[ + Dict[str, int], # node_name -> id + Dict[str, int], # tag_name -> id + Dict[str, int], # bag_name -> id +]: + all_node_names = {n.strip() for it in items for n in it.nodes if n and n.strip()} + all_tag_names = {t.strip() for it in items for t in it.tags} + all_bag_names = {it.bag_name for it in items} + + node_name2id = dict( + db.query(FST.name, FST.id) + .filter(FST.name.in_(all_node_names)) + .distinct() # 防重名 + ) + + tag_name2id = dict( + db.query(ReservedTagList.name, ReservedTagList.id) + .filter( + ReservedTagList.name.in_(all_tag_names), + ReservedTagList.is_deleted.is_(False), + ) + .distinct() + ) + + bag_name2id = dict( + db.query(BagList.name, BagList.id) + .filter(BagList.name.in_(all_bag_names)) + .distinct() + ) + + return node_name2id, tag_name2id, bag_name2id + + +# ---------- 2. Tag 缺失自动插入 ---------- +def _ensure_tags( + db: Session, + all_tag_names: Set[str], + tag_name2id: Dict[str, int], +) -> None: + missing = all_tag_names - tag_name2id.keys() + if not missing: + return + + # PostgreSQL: ON CONFLICT DO NOTHING + stmt = ( + pg_insert(ReservedTagList) + .values([ + { + "name": n, + "type": "auto-insert", + "creator": "system", + "comments": "", + "is_deleted": False, + } + for n in missing + ]) + .on_conflict_do_nothing(index_elements=["name"]) + ) + db.execute(stmt) + db.flush() + + # 重新把刚插入的补全 + tag_name2id.update( + dict( + db.query(ReservedTagList.name, ReservedTagList.id).filter( + ReservedTagList.name.in_(missing) + ) + ) + ) + + +# ---------- 3. 节点链校验 ---------- +def _validate_chain( + nodes: List[str], + node_name2id: Dict[str, int], + fst_parent_map: Dict[int, int], # id -> parent_id +) -> str | None: + """返回错误字符串,无错误返回 None""" + for i in range(1, len(nodes)): + child_id = node_name2id[nodes[i]] + parent_id = node_name2id[nodes[i - 1]] + if fst_parent_map.get(child_id) != parent_id: + return f"{nodes[i]} 的父节点与 {nodes[i - 1]} 不符" + return None + + +# ---------- 4. 单条 upsert ---------- +def _upsert_one_item( + db: Session, + item: FSTBagItem, + node_name2id: Dict[str, int], + tag_name2id: Dict[str, int], + bag_name2id: Dict[str, int], + fst_parent_map: Dict[int, int], + fst_bag_exists: Set[Tuple[int, int]], +) -> FSTBagResult: + try: + # 1) 过滤空节点 + nodes = [n.strip() for n in item.nodes if n and n.strip()] + if not nodes: + return FSTBagResult( + bag_name=item.bag_name, success=False, reason="nodes 列表为空" + ) + + # 2) 节点存在性 + missing = [n for n in nodes if n not in node_name2id] + if missing: + return FSTBagResult( + bag_name=item.bag_name, + success=False, + reason=f"节点不存在: {', '.join(missing)}", + ) + + # 3) 节点链 + err = _validate_chain(nodes, node_name2id, fst_parent_map) + if err: + return FSTBagResult(bag_name=item.bag_name, success=False, reason=err) + + # 4) BagList 创建(ON CONFLICT DO NOTHING) + if item.bag_name not in bag_name2id: + stmt = ( + pg_insert(BagList) + .values(name=item.bag_name) + .on_conflict_do_nothing(index_elements=["name"]) + .returning(BagList.id) + ) + res = db.execute(stmt).fetchone() + if res: + bag_id = res[0] + else: + # 并发已创建,重新查 + bag_id = db.query(BagList.id).filter_by(name=item.bag_name).scalar() + bag_name2id[item.bag_name] = bag_id + else: + bag_id = bag_name2id[item.bag_name] + + # 5) FSTBag upsert + lowest_node_id = node_name2id[nodes[-1]] + key = (bag_id, lowest_node_id) + + if key in fst_bag_exists: + db.query(FSTBag).filter( + FSTBag.bag_id == bag_id, + FSTBag.fst_node_id == lowest_node_id, + ).update( + { + "event_start_time": item.start_time, + "event_end_time": item.end_time, + "comments": item.comments, + "fst_node_level": item.fst_node_level, + }, + synchronize_session=False, + ) + else: + db.add( + FSTBag( + bag_id=bag_id, + fst_node_id=lowest_node_id, + event_start_time=item.start_time, + event_end_time=item.end_time, + comments=item.comments, + fst_node_level=item.fst_node_level, + ) + ) + db.query(FST).filter(FST.id == lowest_node_id).update( + {"bag_sum": (FST.bag_sum or 0) + 1}, + synchronize_session=False, + ) + fst_bag_exists.add(key) + + # 6) Tag 绑定(幂等:先删后插) + valid_tag_ids = [ + tag_name2id[t.strip()] + for t in {t.strip() for t in item.tags} + if t.strip() in tag_name2id + ] + db.query(BagReservedTag).filter(BagReservedTag.bag_id == bag_id).delete( + synchronize_session=False + ) + if valid_tag_ids: + db.bulk_insert_mappings( + BagReservedTag, + [{"bag_id": bag_id, "tag_id": tid} for tid in valid_tag_ids], + ) + + db.commit() + return FSTBagResult(bag_name=item.bag_name, success=True) + + except IntegrityError as e: + db.rollback() + return FSTBagResult(bag_name=item.bag_name, success=False, reason=str(e)) + except Exception as e: + db.rollback() + return FSTBagResult(bag_name=item.bag_name, success=False, reason=str(e)) + + +# ---------- 主入口 ---------- +def batch_upsert_fst_bag( + db: Session, + items: List[FSTBagItem], +) -> List[FSTBagResult]: + if not items: + return [] + + node_name2id, tag_name2id, bag_name2id = _prefetch(db, items) + + all_tag_names = {t.strip() for it in items for t in it.tags} + _ensure_tags(db, all_tag_names, tag_name2id) + + # 预取 FSTBag 已存在的关系 + fst_bag_keys = { + (bag_name2id[it.bag_name], node_name2id[nodes[-1].strip()]) + for it in items + if (nodes := [n.strip() for n in it.nodes if n and n.strip()]) + and nodes[-1].strip() in node_name2id + } + fst_bag_exists = { + (r.bag_id, r.fst_node_id) + for r in db.query(FSTBag.bag_id, FSTBag.fst_node_id).filter( + tuple_(FSTBag.bag_id, FSTBag.fst_node_id).in_(fst_bag_keys) + ) + } + + # 预取 parent_id 用于链校验 + fst_ids = list(node_name2id.values()) + fst_parent_map = dict(db.query(FST.id, FST.parent_id).filter(FST.id.in_(fst_ids))) + + # 逐条处理(每条独立事务) + return [ + _upsert_one_item( + db, + item, + node_name2id, + tag_name2id, + bag_name2id, + fst_parent_map, + fst_bag_exists, + ) + for item in items + ] + + +def upsert_fst_node( + db: Session, + *, + node_id: Optional[int] = None, + name: str, + parent_name: Optional[str] = None, + reserved_json: Optional[Dict[str, Any]] = None, + bag_sum: Optional[int] = None, +) -> FST: + """ + 根据 name UPSERT 一条 FST 记录。 + 如果提供 parent_name,则先查库拿到 parent_id;若查不到抛 ValueError。 + """ + parent_id = None + if parent_name is not None: + parent = db.query(FST).filter(FST.name == parent_name).first() + if not parent: + raise ValueError(f"Parent FST node '{parent_name}' not found") + parent_id = parent.id + if node_id is not None: + node = db.get(FST, node_id) + if node is None: + raise ValueError(f"FST node id '{node_id}' not found") + node.name = name + if parent_name is not None: + node.parent_id = parent_id + if reserved_json is not None: + node.reserved_json = reserved_json + if bag_sum is not None: + node.bag_sum = bag_sum + else: + node = db.query(FST).filter(FST.name == name).first() + if node: + if parent_name is not None: + node.parent_id = parent_id + if reserved_json is not None: + node.reserved_json = reserved_json + if bag_sum is not None: + node.bag_sum = bag_sum + else: + node = FST( + name=name, + parent_id=parent_id, + reserved_json=reserved_json, + bag_sum=bag_sum if bag_sum is not None else 0, + ) + db.add(node) + + try: + db.flush() + except IntegrityError as e: + db.rollback() + raise e + return node + + +# ---------- DELETE ---------- +def delete_fst_by_fst_name(db: Session, name: str) -> None: + """ + 根据 name 进行“逻辑删除”:仅标记 is_delete = 1 + """ + node = db.query(FST).filter(FST.name == name).first() + if not node: + raise ValueError(f"FST node '{name}' not found") + node.is_delete = 1 + db.flush() + + +def get_all_gt_types(db: Session) -> List[Dict]: + """返回全部 GT 类型列表""" + return [ + { + "id": r.id, + "name": r.name, + "type": r.type, + "path": r.path, + "comment": r.comment, + } + for r in db.query(GTMeta).all() + ] + + +def get_bags_by_gt_name(db: Session, gt_name: str) -> Optional[List[Dict]]: + """ + 根据 GT 唯一名称查询关联的 bag 列表 + 若 GT 不存在返回 None;存在但无关联 bag 返回空列表 + """ + gt = db.query(GTMeta).filter(GTMeta.name == gt_name).first() + if not gt: + return None + + bags = ( + db.query(BagList) + .join(BagGT, BagGT.bag_id == BagList.id) + .filter(BagGT.gt_id == gt.id) + .all() + ) + return [b.name for b in bags] + + +# fst_data_pipeline/apps/root_db_api/src/core/service.py +def apply_reserved_tag_with_creator( + db: Session, tag_name: str, bag_names: list, creator: str +): + from fst_data_pipeline.apps.root_db_api.src.core.models import ( + BagList, + ReservedTagList, + BagReservedTag, + ) + + # 1. 获取或创建 reserved_tag + tag = db.query(ReservedTagList).filter(ReservedTagList.name == tag_name).first() + if not tag: + tag = ReservedTagList(name=tag_name, type="user", creator=creator) + db.add(tag) + db.flush() + + total = len(bag_names) + succeeded = skipped = failed = 0 + details = [] + + for name in bag_names: + bag = db.query(BagList).filter(BagList.name == name).first() + if not bag: + failed += 1 + details.append({ + "bag_name": name, + "status": "failed", + "message": "bag not found", + }) + continue + + exists = ( + db.query(BagReservedTag).filter_by(bag_id=bag.id, tag_id=tag.id).first() + ) + if exists: + skipped += 1 + details.append({ + "bag_name": name, + "status": "skipped", + "message": "tag already exists", + }) + continue + + db.add(BagReservedTag(bag_id=bag.id, tag_id=tag.id)) + succeeded += 1 + details.append({ + "bag_name": name, + "status": "success", + "message": "tag applied", + }) + + db.commit() + return { + "tag_name": tag_name, + "creator": creator, + "total": total, + "succeeded": succeeded, + "skipped": skipped, + "failed": failed, + "details": details, + } + + +def link_bags_to_gt_with_stat(db: Session, gt_name: str, bag_names: list): + from fst_data_pipeline.apps.root_db_api.src.core.models import ( + GTMeta, + BagList, + BagGT, + ) + + gt = db.query(GTMeta).filter(GTMeta.name == gt_name).first() + if not gt: + return None + + total = len(bag_names) + succeeded = skipped = failed = 0 + details = [] + + for name in bag_names: + bag = db.query(BagList).filter(BagList.name == name).first() + if not bag: + failed += 1 + details.append({ + "bag_name": name, + "status": "failed", + "message": "bag not found", + }) + continue + + exists = db.query(BagGT).filter_by(gt_id=gt.id, bag_id=bag.id).first() + if exists: + skipped += 1 + details.append({ + "bag_name": name, + "status": "skipped", + "message": "already linked", + }) + continue + + db.add(BagGT(gt_id=gt.id, bag_id=bag.id)) + succeeded += 1 + details.append({ + "bag_name": name, + "status": "success", + "message": "linked", + }) + + db.commit() + return { + "gt_name": gt_name, + "total": total, + "succeeded": succeeded, + "skipped": skipped, + "failed": failed, + "details": details, + } + + +def create_version( + db: Session, + version: str, + type: Optional[str] = None, + description: Optional[str] = None, + release_date: Optional[datetime] = None, + status: Optional[str] = None, + feishu_sync_status: Optional[str] = None, +) -> VersionList: + obj = VersionList( + version=version, + type=type, + description=description, + release_date=release_date, + status=status or "available", + feishu_sync_status=feishu_sync_status or "unsynced", + ) + db.add(obj) + db.flush() + return obj + + +def list_versions(db: Session) -> List[Dict[str, Any]]: + rows = ( + db.query(VersionList) + .order_by(VersionList.update_time.desc(), VersionList.id.desc()) + .all() + ) + return [ + { + "id": r.id, + "version": r.version, + "type": r.type, + "description": r.description, + "release_date": r.release_date, + "created_time": r.created_time, + "update_time": r.update_time, + "status": r.status, + "feishu_sync_status": r.feishu_sync_status, + } + for r in rows + ] + + +def update_version_status( + db: Session, + version_id: int, + status: str, + description: Optional[str] = None, +) -> Optional[VersionList]: + obj = db.get(VersionList, version_id) + if obj is None: + return None + obj.status = status + if description is not None: + obj.description = description + db.flush() + return obj + + +def delete_version( + db: Session, + version_id: int, +) -> bool: + obj = db.get(VersionList, version_id) + if obj is None: + return False + db.delete(obj) + db.flush() + return True + + +def build_fst_snapshot() -> List[Dict[str, Any]]: + return print_fst_tree() + + +def save_version_snapshot(db: Session, version_id: int) -> Optional[Dict[str, Any]]: + obj = db.get(VersionList, version_id) + if obj is None: + return None + snapshot = build_fst_snapshot() + payload = obj.reserved_json or {} + payload["fst_tree_snapshot"] = { + "created_at": datetime.utcnow().isoformat(), + "data": snapshot, + } + obj.reserved_json = payload + db.flush() + return snapshot + + +def get_version_snapshot(db: Session, version_id: int) -> Optional[Dict[str, Any]]: + obj = db.get(VersionList, version_id) + if obj is None: + return None + payload = obj.reserved_json or {} + snapshot = payload.get("fst_tree_snapshot") or payload.get("fst_snapshot") + if not snapshot: + return None + data = snapshot.get("data") + if isinstance(data, list): + return data + if isinstance(data, dict) and "nodes" in data: + nodes = data.get("nodes") or [] + id_to_node: Dict[int, Dict[str, Any]] = {} + for n in nodes: + try: + nid = int(n.get("id")) + except (TypeError, ValueError): + continue + id_to_node[nid] = { + "id": n.get("name"), + "label": n.get("name"), + "level": 0, + "children": [], + } + roots: List[Dict[str, Any]] = [] + for n in nodes: + try: + nid = int(n.get("id")) + except (TypeError, ValueError): + continue + pid = n.get("parent_id") + pid_int = int(pid) if isinstance(pid, int) or (isinstance(pid, str) and pid.isdigit()) else None + if pid_int is not None and pid_int in id_to_node: + id_to_node[pid_int]["children"].append(id_to_node[nid]) + else: + roots.append(id_to_node[nid]) + + def _set_level(node: Dict[str, Any], lv: int) -> None: + node["level"] = lv + for ch in node["children"]: + _set_level(ch, lv + 1) + + for r in roots: + _set_level(r, 0) + return roots + return None + + +def fst_bags_detail(db: Session, fst_name: str) -> Dict[str, List[Dict[str, Any]]]: + """ + 根据 FST 名(精确匹配)返回其及所有子孙节点下的 bag 列表, + 每条 bag 已携带 Pangu 详情(decodedDir/tosPath/mViz/mbViz/comment)。 + 返回格式: + { + "fst_node_name": [ + { + "bagName": "xxx.bag", + "decodedDir": "...", + "tosPath": "...", + "mVizUrl": "...", + "mbVizUrl": "...", + "comment": "..." + } + ] + } + """ + if not fst_name: + return {} + + sql = text(""" + WITH RECURSIVE sub AS ( + SELECT id, parent_id, name + FROM fst + WHERE name = :name + UNION ALL + SELECT f.id, f.parent_id, f.name + FROM fst f + JOIN sub s ON f.parent_id = s.id + ) + SELECT s.name AS fst_name, + bl.name AS bag_name, + mp.data_path AS decodedDir, + mp.bag_path AS tosPath + FROM sub s + JOIN fst_bag fb ON fb.fst_node_id = s.id + JOIN bag_list bl ON bl.id = fb.bag_id + LEFT JOIN main_pangu mp ON mp.name = bl.name + ORDER BY s.name, bl.name; + """).bindparams(name=fst_name) + + rows = db.execute(sql).all() + + out: Dict[str, List[Dict[str, Any]]] = {} + for r in rows: + fst_name, bag_name, decodedDir, tosPath = r + out.setdefault(fst_name, []).append({ + "bagName": bag_name, + "decodedDir": decodedDir or "", + "tosPath": tosPath or "", + "mVizUrl": "", + "mbVizUrl": "", + "comment": "", + }) + + return out + + +# ============================================================ +# Recompute 结果存储相关服务函数 +# ============================================================ + + +def get_bag_recompute_versions( + db: Session, bag_names: List[str] +) -> Dict[str, List[str]]: + """批量获取bag的recompute版本列表""" + # 查询每个bag对应的所有recompute版本 + results = ( + db.query(BagList.name.label("bag_name"), RecomputeResult.recompute_version) + .join(RecomputeResult, BagList.id == RecomputeResult.bag_id) + .filter(BagList.name.in_(bag_names)) + .all() + ) + + # 组织结果为字典格式:{bag_name: [versions]} + bag_versions = {} + for bag_name in bag_names: + bag_versions[bag_name] = [] + + for result in results: + if result.bag_name not in bag_versions: + bag_versions[result.bag_name] = [] + if result.recompute_version not in bag_versions[result.bag_name]: + bag_versions[result.bag_name].append(result.recompute_version) + + # 对每个bag的版本列表进行排序 + for bag_name in bag_versions: + bag_versions[bag_name].sort() + + return bag_versions + + +def get_storage_paths_batch( + db: Session, requests: List[Dict[str, str]] +) -> List[Dict[str, Any]]: + """批量获取存储路径 + + Args: + requests: 包含bag_name和recompute_version的请求列表 + + Returns: + 结果列表,每个元素包含查询条件和对应的存储信息 + """ + all_results = [] + + for request in requests: + bag_name = request.get("bag_name") + recompute_version = request.get("recompute_version") + + if not bag_name or not recompute_version: + continue + + # 查询指定bag和版本的所有结果 + results = ( + db.query( + RecomputeResult.id.label("result_id"), + BagList.name.label("bag_name"), + RecomputeResult.recompute_version, + RecomputeResult.result_type, + RecomputeResult.storage_path, + RecomputeResult.status, + RecomputeResult.created_time, + RecomputeResult.reserved_json, + ) + .join(BagList, RecomputeResult.bag_id == BagList.id) + .filter( + BagList.name == bag_name, + RecomputeResult.recompute_version == recompute_version, + ) + .order_by(RecomputeResult.created_time.desc()) + .all() + ) + + # 格式化单个请求的结果 + request_results = [] + for result in results: + request_results.append({ + "result_id": result.result_id, + "bag_name": result.bag_name, + "recompute_version": result.recompute_version, + "result_type": result.result_type, + "storage_path": result.storage_path, + "status": result.status, + "created_time": result.created_time.isoformat() + if result.created_time + else None, + "reserved_json": result.reserved_json, + }) + + all_results.append({ + "bag_name": bag_name, + "recompute_version": recompute_version, + "results": request_results, + }) + + return all_results + + +NODE_VERSION_FIELD = "node_version" +NODE_VERSION_DEFAULT = 1 + + +class StashNodeVersionConflictError(ValueError): + def __init__(self, conflicts: List[Dict[str, Any]]): + self.conflicts = conflicts + super().__init__("stash node version conflict") + + +def _coerce_stash_node_version(raw: Any) -> int: + try: + val = int(raw) + except (TypeError, ValueError): + return NODE_VERSION_DEFAULT + return val if val > 0 else NODE_VERSION_DEFAULT + + +def _stash_node_signature(node: Dict[str, Any]) -> Dict[str, Any]: + # children payload is tracked on child nodes themselves. + return { + k: v + for k, v in node.items() + if k not in (NODE_VERSION_FIELD, "children") + } + + +def _collect_stash_node_maps( + stash_content: Any, + *, + mutate_version: bool, +) -> Tuple[Dict[str, int], Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]: + versions: Dict[str, int] = {} + signatures: Dict[str, Dict[str, Any]] = {} + refs: Dict[str, Dict[str, Any]] = {} + + if not isinstance(stash_content, dict): + return versions, signatures, refs + + root_nodes = stash_content.get("nodes") + if not isinstance(root_nodes, list): + return versions, signatures, refs + + def _walk(nodes: List[Any], parent_path: str) -> None: + for idx, node in enumerate(nodes): + if not isinstance(node, dict): + continue + + node_name = str(node.get("name") or f"node_{idx}") + path = ( + f"{parent_path}/{node_name}[{idx}]" + if parent_path + else f"/{node_name}[{idx}]" + ) + node_id = node.get("id") + key = f"id:{node_id}" if node_id is not None else f"path:{path}" + + node_version = _coerce_stash_node_version(node.get(NODE_VERSION_FIELD)) + if mutate_version: + node[NODE_VERSION_FIELD] = node_version + + versions[key] = node_version + signatures[key] = _stash_node_signature(node) + refs[key] = node + + children = node.get("children") + if isinstance(children, list): + _walk(children, path) + + _walk(root_nodes, "") + return versions, signatures, refs + + +def normalize_stash_node_versions(content: Any) -> Any: + if not isinstance(content, dict): + return content + normalized = copy.deepcopy(content) + _collect_stash_node_maps(normalized, mutate_version=True) + return normalized + + +def _prepare_stash_content_for_update( + incoming_content: Any, + existing_content: Any, + *, + use_node_optimistic_lock: bool, +) -> Any: + normalized_incoming = normalize_stash_node_versions(incoming_content) + if not isinstance(normalized_incoming, dict): + return normalized_incoming + + incoming_versions, incoming_signatures, incoming_refs = _collect_stash_node_maps( + normalized_incoming, + mutate_version=True, + ) + existing_versions, existing_signatures, _ = _collect_stash_node_maps( + existing_content, + mutate_version=False, + ) + + if use_node_optimistic_lock: + conflicts: List[Dict[str, Any]] = [] + for node_key, existing_version in existing_versions.items(): + incoming_version = incoming_versions.get(node_key) + if incoming_version is None: + continue + if incoming_version != existing_version: + conflicts.append({ + "node_key": node_key, + "expected_node_version": existing_version, + "provided_node_version": incoming_version, + }) + + if conflicts: + raise StashNodeVersionConflictError(conflicts) + + for node_key, node_ref in incoming_refs.items(): + existing_version = existing_versions.get(node_key) + if existing_version is None: + node_ref[NODE_VERSION_FIELD] = NODE_VERSION_DEFAULT + continue + + if incoming_signatures.get(node_key) != existing_signatures.get(node_key): + node_ref[NODE_VERSION_FIELD] = existing_version + 1 + else: + node_ref[NODE_VERSION_FIELD] = existing_version + + return normalized_incoming + + +def upsert_fst_stash( + db: Session, + *, + version: str, + content: Optional[Dict[str, Any]], + use_node_optimistic_lock: bool = False, +) -> "FstStash": + """Insert or update FST stash record for a version. + + If a record exists for the given version, update its content. + If not, create a new record. + """ + from fst_data_pipeline.apps.root_db_api.src.core.models import FstStash, VersionList + + # 1. Check if version exists in version_list + v_obj = db.query(VersionList).filter(VersionList.version == version).first() + if not v_obj: + raise ValueError(f"Version '{version}' not found in version_list") + + # 2. Check if stash exists + stash = db.query(FstStash).filter(FstStash.fst_versions == version).first() + + if stash: + if content is not None: + stash.content = _prepare_stash_content_for_update( + content, + stash.content, + use_node_optimistic_lock=use_node_optimistic_lock, + ) + else: + stash = FstStash( + fst_versions=version, + content=normalize_stash_node_versions(content), + ) + db.add(stash) + + db.flush() + return stash + + +def get_fst_stash(db: Session, version: str) -> Optional["FstStash"]: + """Get FST stash record by version.""" + from fst_data_pipeline.apps.root_db_api.src.core.models import FstStash + return db.query(FstStash).filter(FstStash.fst_versions == version).first() + + +def diff_fst_stash_versions( + db: Session, + from_version: str, + to_version: str, +) -> Dict[str, Any]: + """Compute diff between two fst_stash versions based on nodes array.""" + from fst_data_pipeline.apps.root_db_api.src.core.models import FstStash + + if not from_version or not to_version: + raise ValueError("Both 'from_version' and 'to_version' are required") + + def _load_nodes(v: str) -> List[Dict[str, Any]]: + obj: Optional[FstStash] = ( + db.query(FstStash).filter(FstStash.fst_versions == v).first() + ) + if obj is None or obj.content is None: + raise ValueError(f"Stash not found or empty for version '{v}'") + content = obj.content or {} + nodes = content.get("nodes") + if not isinstance(nodes, list): + raise ValueError(f"Stash content for version '{v}' must contain a 'nodes' array") + return nodes + + def _build_path_index(nodes: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + has_id = any("id" in n for n in nodes) + if has_id: + by_id: Dict[Any, Dict[str, Any]] = {} + for n in nodes: + node_id = n.get("id") + if node_id is None: + continue + by_id[node_id] = n + + path_cache: Dict[Any, Optional[str]] = {} + + def build_path(node_id: Any) -> Optional[str]: + if node_id in path_cache: + return path_cache[node_id] + node = by_id.get(node_id) + if not node: + path_cache[node_id] = None + return None + name = str(node.get("name") or "") + parent_id = node.get("parentId") + if parent_id is None or parent_id not in by_id: + path = name + else: + parent_path = build_path(parent_id) + if not parent_path: + path = name + else: + path = parent_path + "/" + name + path_cache[node_id] = path + return path + + index: Dict[str, Dict[str, Any]] = {} + for node_id, node in by_id.items(): + path = build_path(node_id) + if not path: + continue + full_path = "/" + path if not path.startswith("/") else path + normalized = { + k: v for k, v in node.items() if k not in ("id", "parentId") + } + index[full_path] = normalized + return index + + index: Dict[str, Dict[str, Any]] = {} + + def walk(node: Dict[str, Any], parent_path: str) -> None: + name = str(node.get("name") or "") + if not name: + return + full_path = parent_path + "/" + name if parent_path else "/" + name + normalized = {k: v for k, v in node.items() if k != "children"} + index[full_path] = normalized + children = node.get("children") or [] + for child in children: + if isinstance(child, dict): + walk(child, full_path) + + for root in nodes: + if isinstance(root, dict): + walk(root, "") + + return index + + nodes_from = _load_nodes(from_version) + nodes_to = _load_nodes(to_version) + + index_from = _build_path_index(nodes_from) + index_to = _build_path_index(nodes_to) + + paths_from = set(index_from.keys()) + paths_to = set(index_to.keys()) + + added_paths = sorted(paths_to - paths_from) + removed_paths = sorted(paths_from - paths_to) + common_paths = paths_from & paths_to + + added: List[Dict[str, Any]] = [ + {"path": p, "node": index_to[p]} for p in added_paths + ] + removed: List[Dict[str, Any]] = [ + {"path": p, "node": index_from[p]} for p in removed_paths + ] + changed: List[Dict[str, Any]] = [] + + def _is_deleted(val: Any) -> bool: + return val in (1, "1", True) + + for p in sorted(common_paths): + before = index_from[p] + after = index_to[p] + if before == after: + continue + before_deleted = _is_deleted(before.get("deleted")) + after_deleted = _is_deleted(after.get("deleted")) + + if not before_deleted and after_deleted: + removed.append({"path": p, "node": before}) + elif before_deleted and not after_deleted: + added.append({"path": p, "node": after}) + else: + changed.append({ + "path": p, + "before": before, + "after": after, + }) + + return { + "summary": { + "from_version": from_version, + "to_version": to_version, + "added_count": len(added), + "removed_count": len(removed), + "changed_count": len(changed), + }, + "added": added, + "removed": removed, + "changed": changed, + } diff --git a/fst_data_pipeline/apps/root_db_api/src/db/__init__.py b/fst_data_pipeline/apps/root_db_api/src/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/root_db_api/src/db/cache.py b/fst_data_pipeline/apps/root_db_api/src/db/cache.py new file mode 100644 index 0000000..c55526b --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/db/cache.py @@ -0,0 +1,36 @@ +# fst_data_pipeline/apps/root_db_api/src/db/memory_cache.py +import threading +import time +from typing import Any, Optional + + +class _MemoryCache: + """极简带过期时间的线程安全内存缓存""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._data: dict[str, tuple[Any, float]] = {} + + def get(self, key: str) -> Optional[Any]: + with self._lock: + item = self._data.get(key) + if item is None: + return None + value, expire = item + if expire > 0 and time.time() > expire: + self._data.pop(key, None) + return None + return value + + def set(self, key: str, value: Any, timeout: int = 600) -> None: + expire = time.time() + timeout if timeout > 0 else 0 + with self._lock: + self._data[key] = (value, expire) + + def delete(self, key: str) -> None: + with self._lock: + self._data.pop(key, None) + + +# 全局单例 +memory_cache = _MemoryCache() diff --git a/fst_data_pipeline/apps/root_db_api/src/db/connection.py b/fst_data_pipeline/apps/root_db_api/src/db/connection.py new file mode 100644 index 0000000..7622846 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/db/connection.py @@ -0,0 +1,30 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from contextlib import contextmanager + +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_BASE_URL = os.getenv("DB_BASE_URL") +DB_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_BASE_URL}" + +engine = create_engine( + DB_URL, + pool_size=20, + max_overflow=40, + pool_pre_ping=True, + pool_recycle=3600, + connect_args={"options": "-c search_path=public"}, + echo=True, +) + +SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) + + +@contextmanager +def get_db_session(): + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/PKG-INFO b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/PKG-INFO new file mode 100644 index 0000000..b39960c --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/PKG-INFO @@ -0,0 +1,1702 @@ +Metadata-Version: 2.4 +Name: root_db_api +Version: 0.5.0 +Summary: A core API service for database operations. +Author-email: Cheng Li +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: flask>=3.1.1 +Requires-Dist: flasgger==0.9.7b2 +Requires-Dist: geoalchemy2==0.17.1 +Requires-Dist: shapely>=2.1.1 +Requires-Dist: numpy==2.3.1 +Requires-Dist: folium==0.20.0 +Requires-Dist: tenacity==9.1.2 +Requires-Dist: tqdm==4.67.1 +Requires-Dist: pytz==2025.2 +Requires-Dist: prometheus-client==0.22.1 +Requires-Dist: flask-caching>=2.3.1 +Requires-Dist: redis>=6.4.0 +Requires-Dist: cos-python-sdk-v5==1.9.37 +Requires-Dist: python-dotenv==1.1.1 +Requires-Dist: requests==2.32.4 +Requires-Dist: pyyaml==6.0.2 +Requires-Dist: pydantic==2.11.7 +Requires-Dist: sqlalchemy==2.0.41 +Requires-Dist: psycopg2-binary==2.9.10 +Requires-Dist: openpyxl>=3.1.0 +Requires-Dist: gunicorn>=23.0.0 +Requires-Dist: pytest==8.4.1 +Requires-Dist: pytest-cov==6.2.0 +Requires-Dist: pytest-mock==3.14.0 +Requires-Dist: flask-sqlalchemy>=3.1.1 + +# ROOT DB API + +A core API service for database operations in the FST (File System Tree) data pipeline. + +![Version](https://img.shields.io/badge/version-0.5.0-blue.svg) +![Python](https://img.shields.io/badge/python-3.12+-green.svg) +![Flask](https://img.shields.io/badge/flask-3.1.1+-red.svg) + +## 📝 Description + +ROOT DB API 是基于Flask的REST API服务,为自动驾驶数据管理提供全面的数据库操作功能,包括bag文件、FST节点、项目、标签、主题、几何数据和真值数据的管理。具备完整的Swagger/OpenAPI中文文档和强大的数据管理能力。 + +## 🚀 Features + +- **REST API接口** 提供全面的数据管理功能 +- **Swagger/OpenAPI文档** 提供交互式UI界面 +- **PostgreSQL数据库** 采用SQLAlchemy ORM +- **地理空间支持** 集成PostGIS和Shapely +- **完善测试覆盖** 基于pytest框架 +- **生产环境就绪** 支持Gunicorn部署 + +## 🛠 Tech Stack + +| 组件 | 技术栈 | +|------|--------| +| **Web框架** | Flask 3.1.1+ | +| **数据库** | PostgreSQL + SQLAlchemy ORM | +| **API文档** | Swagger/OpenAPI (Flasgger) | +| **地理空间** | GeoAlchemy2 + Shapely + Folium | +| **部署服务** | Gunicorn | +| **测试框架** | pytest + pytest-cov + pytest-mock | +| **包管理器** | uv | + +## 📋 API Endpoints + +### 🗂️ Bag管理 API (`/api/bags`) + +#### `GET /api/bags/all` +Get all bags grouped by project with complete Pangu and Minerva data. + +**Response Schema:** +```json +{ + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "project_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"}, + "tile_id": {"type": "string"}, + "is_decoded": {"type": "boolean"}, + "pangu_data": {"type": "object"}, + "minerva_data": {"type": "object"} + } + } + } + } +} +``` + +**Example Response:** +```json +{ + "project_dfdi": [ + { + "bag_name": "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "project_id": 1, + "update_time": "2023-08-07T11:56:27Z", + "tile_id": "12345", + "is_decoded": true, + "pangu_data": { + "vehicle": "PL061763", + "datetime": "2023-08-07T11:56:27Z", + "bag_path": "/path/to/bag", + "data_path": "/path/to/data" + }, + "minerva_data": null + } + ] +} +``` + +#### `POST /api/bags/pangu` +Get basic Pangu data by bag names. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "bag_path": {"type": "string"}, + "data_path": {"type": "string"} + } + } + } + } +} +``` + +**Example Request:** +```json +{ + "names": [ + "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "PL061763_event_manual_recording_20230726-150140_0.bag.dir" + ] +} +``` + +**Example Response:** +```json +{ + "data": [ + { + "bag_name": "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "vehicle": "PL061763", + "datetime": "2023-08-07T11:56:27Z", + "bag_path": "/pangu/bags/PL061763_event_ld_gps_event_20230807-115627_0.bag.dir", + "data_path": "/pangu/data/PL061763_event_ld_gps_event_20230807-115627_0" + } + ] +} +``` + +#### `POST /api/bags/pangu/detail` +Get detailed Pangu information including file paths for all sensors. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "pangu_basic": {"type": "object"}, + "pangu_paths": { + "type": "object", + "properties": { + "lidar_gt_pandar128": {"type": "string"}, + "camera_fisheye_left": {"type": "string"}, + "camera_fisheye_right": {"type": "string"}, + "camera_front_wide": {"type": "string"}, + "raw_gps": {"type": "string"}, + "raw_imu": {"type": "string"}, + "ego_motion": {"type": "string"}, + "calibration": {"type": "string"} + } + } + } + } + } + } +} +``` + +#### `POST /api/bags/topics` +Get topics information by bag names. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "topic_name": {"type": "string"}, + "message_type": {"type": "string"}, + "key_data": {"type": "string"} + } + } + } + } + } + } + } +} +``` + +#### `POST /api/bags/tags` +Get tags associated with specific bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["names"], + "properties": { + "names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag_name": {"type": "string"}, + "tag_type": {"type": "string"}, + "creator": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } + } + } + } +} +``` + +#### `GET /api/bags/search/pangu` +Search Pangu data with various filter conditions. + +**Query Parameters:** +- `project_id` (optional, integer): Filter by project ID +- `vehicle` (optional, string): Filter by vehicle name +- `start_date` (optional, string): Start date filter (YYYY-MM-DD format) +- `end_date` (optional, string): End date filter (YYYY-MM-DD format) +- `limit` (optional, integer): Limit results (default: 100, max: 1000) +- `offset` (optional, integer): Offset for pagination (default: 0) + +**Example Request:** +``` +GET /api/bags/search/pangu?project_id=1&vehicle=PL061763&limit=50&offset=0 +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "project_id": {"type": "integer"}, + "data_path": {"type": "string"} + } + } + }, + "pagination": { + "type": "object", + "properties": { + "total": {"type": "integer"}, + "limit": {"type": "integer"}, + "offset": {"type": "integer"}, + "has_more": {"type": "boolean"} + } + } + } +} +``` + +### 🌳 FST管理 API (`/api/fst`) + +#### `GET /api/fst/print_tree` +Get complete FST tree structure with hierarchical relationships. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tree_structure": { + "type": "object", + "properties": { + "root_nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "bag_sum": {"type": "integer"}, + "children": {"type": "array"} + } + } + } + } + }, + "statistics": { + "type": "object", + "properties": { + "total_nodes": {"type": "integer"}, + "total_bags": {"type": "integer"}, + "max_depth": {"type": "integer"} + } + } + } +} +``` + +#### `GET /api/fst/all_nodes` +Get all FST nodes with metadata. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "label": {"type": "string"}, + "parent_id": {"type": "integer", "nullable": true} + } + } + } + } +} +``` + +#### `POST /api/fst/bags/nodes` +Get FST node assignments for specific bags with pagination support. + +**Query Parameters:** +- `page` (optional, integer, default: 1): Page number +- `per_page` (optional, integer, default: 20, max: 100): Items per page + +**Request Schema:** +```json +{ + "type": "array", + "items": {"type": "string"}, + "example": ["fst_node1", "fst_node2"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "sts": {"type": "string"} + } + } + }, + "page": {"type": "integer"}, + "per_page": {"type": "integer"}, + "total": {"type": "integer"} + } +} +``` + +#### `GET /api/fst/bags/path/{name}` +Get bags under an FST node and its descendants. + +**Path Parameters:** +- `name` (required, string): The FST node name + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": {"type": "string"} + } + } + } +} +``` + +#### `GET /api/fst/baglist` +Get complete FST tree structure with associated bags including event timing and comments. + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "parent_id": {"type": "integer", "nullable": true}, + "parent_name": {"type": "string", "nullable": true}, + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "event_start_time": {"type": "integer", "nullable": true}, + "event_end_time": {"type": "integer", "nullable": true}, + "comments": {"type": "string", "nullable": true} + } + } + }, + "children": { + "type": "object", + "description": "Child FST nodes with same structure" + } + } + } +} +``` + +**Example Response:** +```json +{ + "1": { + "id": 1, + "name": "highway_scenarios", + "parent_id": null, + "parent_name": null, + "bags": [ + { + "bag_name": "PL061763_highway_20230807-115627_0.bag.dir", + "event_start_time": 1000, + "event_end_time": 2000, + "comments": "Highway driving scenario with lane changes" + } + ], + "children": { + "2": { + "id": 2, + "name": "lane_change", + "parent_id": 1, + "parent_name": "highway_scenarios", + "bags": [], + "children": {} + } + } + } +} +``` + +#### `POST /api/fst/bags/update` +Batch create or update FST-Bag associations. + +**Request Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "required": ["bag_name", "nodes", "start", "end"], + "properties": { + "bag_name": {"type": "string"}, + "nodes": { + "type": "array", + "items": {"type": "string"}, + "description": "Ordered FST node names (parent → child)" + }, + "start": {"type": "integer", "description": "Event start time"}, + "end": {"type": "integer", "description": "Event end time"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "success": {"type": "boolean"}, + "reason": {"type": "string", "nullable": true} + } + } +} +``` + +#### `DELETE /api/fst/{name}` +Delete an FST node by name. + +**Path Parameters:** +- `name` (required, string): FST node name to delete + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "ok": {"type": "boolean"} + } +} +``` + +#### `GET /api/fst/{name}/bags` +Get bags and Pangu details under an FST and its descendants. + +**Path Parameters:** +- `name` (required, string): The FST node name + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bagName": {"type": "string"}, + "decodedDir": {"type": "string"}, + "tosPath": {"type": "string"}, + "mVizUrl": {"type": "string"}, + "mbVizUrl": {"type": "string"}, + "comment": {"type": "string"} + } + } + } + } + } + } + } +} +``` + +#### `GET /api/fst/{name}` +Get specific FST node by name. + +**Path Parameters:** +- `name` (required, string): FST node name + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "bag_sum": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"}, + "children": {"type": "array"}, + "parent_path": {"type": "string"} + } +} +``` + +#### `POST /api/fst/update` +Update FST node information. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "reserved_json": {"type": "object"} + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "updated_node": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } +} +``` + +### 📁 项目管理 API (`/api/projects`) + +#### `GET /api/projects/all` +Get all projects with bag statistics. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"}, + "bag_count": {"type": "integer"}, + "decoded_count": {"type": "integer"}, + "active_count": {"type": "integer"} + } + } + } + } +} +``` + +### 🏷️ 标签管理 API (`/api/tags`) + +#### `GET /api/tags/all` +Get all available tags. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "creator": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"}, + "comments": {"type": "string"}, + "is_deleted": {"type": "boolean"} + } + } + } + } +} +``` + +#### `GET /api/tags/bags` +Get tags with associated bag counts. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tag_statistics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag_name": {"type": "string"}, + "tag_type": {"type": "string"}, + "bag_count": {"type": "integer"}, + "creator": {"type": "string"} + } + } + } + } +} +``` + +#### `GET /api/tags/bags` +Get bags by tag names with intersection or union logic. + +**Query Parameters:** +- `tags` (required, string): Comma-separated tag names (e.g., "A,B,C") +- `op` (optional, string): Operation type - "and" for intersection, "or" for union (default: "and") + +**Example Request:** +``` +GET /api/tags/bags?tags=driving,city&op=and +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"} + } + } + } + } +} +``` + +#### `GET /api/tags/creators/{creator}` +Get tags created by a specific creator. + +**Path Parameters:** +- `creator` (required, string): Creator identifier (username or email) + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +#### `POST /api/tags/{tag_name}/bags` +Batch apply tags to bags with creator tracking. + +**Path Parameters:** +- `tag_name` (required, string): Tag name + +**Query Parameters:** +- `creator` (optional, string, default: "system"): Creator identifier + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "tag_name": {"type": "string"}, + "creator": {"type": "string"}, + "total": {"type": "integer"}, + "succeeded": {"type": "integer"}, + "skipped": {"type": "integer"}, + "failed": {"type": "integer"}, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "status": {"type": "string", "enum": ["success", "skipped", "failed"]}, + "message": {"type": "string"} + } + } + } + } +} +``` + +### 📖 主题管理 API (`/api/topics`) + +#### `GET /api/topics/all` +Get all available ROS topics. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "key_data": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } +} +``` + +#### `GET /api/topics/bags/{topic_name}` +Get bags containing specific topic. + +**Path Parameters:** +- `topic_name` (required, string): Topic name (URL encoded) + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "topic_name": {"type": "string"}, + "topic_info": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "key_data": {"type": "string"} + } + }, + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "project_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } +} +``` + +### 🗺️ 几何数据管理 API (`/api/geometry`) + +#### `POST /api/geometry/` +Process geospatial geometry data and perform spatial operations. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["rosbag_names"], + "properties": { + "rosbag_names": { + "type": "array", + "items": {"type": "string"} + }, + "operation": { + "type": "string", + "enum": ["get_trajectory", "check_overlap", "get_bounds"], + "default": "get_trajectory" + }, + "downsample_factor": {"type": "integer", "default": 1} + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rosbag_name": {"type": "string"}, + "trajectory": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["LineString", "MultiPoint"]}, + "coordinates": {"type": "array"} + } + }, + "is_overlapped": {"type": "boolean"}, + "update_time": {"type": "string", "format": "date-time"} + } + } + } + } +} +``` + +### 🎯 真值数据管理 API (`/api/gt`) + +#### `GET /api/gt/types` +Get all available Ground Truth data types. + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "gt_types": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "path": {"type": "string"}, + "update_time": {"type": "string", "format": "date-time"}, + "comment": {"type": "string"} + } + } + } + } +} +``` + +#### `GET /api/gt/{gt_name}/bags` +Get bags associated with specific Ground Truth data. + +**Path Parameters:** +- `gt_name` (required, string): Ground Truth name + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "gt_name": {"type": "string"}, + "gt_info": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "path": {"type": "string"}, + "comment": {"type": "string"} + } + }, + "bags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "project_id": {"type": "integer"}, + "update_time": {"type": "string", "format": "date-time"}, + "comment": {"type": "string"} + } + } + } + } +} +``` + +#### `POST /api/bags/minerva` +Get Minerva data by session IDs. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["session_ids"], + "properties": { + "session_ids": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Example Request:** +```json +{ + "session_ids": ["session_123", "session_456"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "vin": {"type": "string"}, + "start_ts": {"type": "number"}, + "end_ts": {"type": "number"}, + "platform": {"type": "string"}, + "path": {"type": "string"}, + "converted_path": {"type": "string"}, + "gt_path": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "length": {"type": "number"}, + "reserved_json": {"type": "object"} + } + } + } +} +``` + +#### `POST /api/bags/minerva/detail` +Get detailed Minerva information by bag names. + +**Request Schema:** +```json +{ + "type": "array", + "items": {"type": "string"}, + "example": ["bag1.bag", "bag2.bag"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "vin": {"type": "string"}, + "platform": {"type": "string"}, + "path": {"type": "string"}, + "converted_path": {"type": "string"}, + "gt_path": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"} + } + } +} +``` + +#### `POST /api/bags/life_cycle` +Get lifecycle information for specific bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "collect_time": {"type": "string", "format": "date-time"}, + "clone2dev_time": {"type": "string", "format": "date-time"}, + "decode_time": {"type": "string", "format": "date-time"}, + "mining_time": {"type": "string", "format": "date-time"}, + "auto_annotate_time": {"type": "string", "format": "date-time"}, + "manual_annotate_time": {"type": "string", "format": "date-time"}, + "fst_index_time": {"type": "string", "format": "date-time"} + } + } +} +``` + +#### `GET /api/bags/joined` +Get related bags before/after a specified bag by datetime. + +**Query Parameters:** +- `bag_name` (required, string): Name of the central bag +- `before` (optional, integer, default: 5): How many bags to return before +- `after` (optional, integer, default: 5): How many bags to return after + +**Response Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "vehicle": {"type": "string"}, + "datetime": {"type": "string", "format": "date-time"}, + "bag_path": {"type": "string"}, + "data_path": {"type": "string"} + } + } +} +``` + +#### `POST /api/bags/joined/create` +Create a merged parent bag from sub-bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "joined_id": {"type": "integer"}, + "joined_name": {"type": "string"} + } +} +``` + +#### `POST /api/bags/joined/query` +Query existing merged relationships by bag names. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"} + }, + "example": { + "parent_merged": ["child1", "child2", "child3"], + "parent2_merged": ["child4", "child5"] + } +} +``` + +#### `POST /api/bags/joined/delete` +Delete merged parent bags and restore child bags. + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + } + } +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "deleted_parents": { + "type": "array", + "items": {"type": "string"} + } + } +} +``` + +#### `GET /api/bags/version` +Query bags by software/hardware version patterns. + +**Query Parameters:** +- `sw` (optional, string): Software version pattern (supports * wildcards) +- `hw` (optional, string): Hardware version pattern (supports * wildcards) + +**Example Request:** +``` +GET /api/bags/version?sw=1.2.*&hw=*rev3* +``` + +**Response Schema:** +```json +{ + "type": "array", + "items": {"type": "string"}, + "example": ["bag1.bag", "bag2.bag"] +} +``` + +### 🔄 重计算管理 API (`/api/recompute`) + +#### `POST /api/recompute/versions` +Get recompute versions for bags (batch). + +**Request Schema:** +```json +{ + "type": "object", + "required": ["bag_names"], + "properties": { + "bag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "List of bag names" + } + } +} +``` + +**Example Request:** +```json +{ + "bag_names": ["bag1.bag", "bag2.bag", "bag3.bag"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"} + }, + "example": { + "bag1.bag": ["v1.0", "v1.1", "v2.0"], + "bag2.bag": ["v1.0", "v2.0"], + "bag3.bag": [] + } +} +``` + +#### `POST /api/recompute/storage` +Get storage paths for bags and versions (batch). + +**Request Schema:** +```json +{ + "type": "object", + "required": ["requests"], + "properties": { + "requests": { + "type": "array", + "items": { + "type": "object", + "required": ["bag_name", "recompute_version"], + "properties": { + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"} + } + } + } + } +} +``` + +**Example Request:** +```json +{ + "requests": [ + {"bag_name": "bag1.bag", "recompute_version": "v1.0"}, + {"bag_name": "bag2.bag", "recompute_version": "v1.1"} + ] +} +``` + +**Response Schema:** +```json +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"}, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "result_id": {"type": "integer"}, + "bag_name": {"type": "string"}, + "recompute_version": {"type": "string"}, + "result_type": {"type": "string"}, + "storage_path": {"type": "string"}, + "status": {"type": "string"}, + "created_time": {"type": "string", "format": "date-time"}, + "reserved_json": {"type": "object"} + } + } + } + } + } +} +``` + +## 🚀 Quick Start + +### Prerequisites + +- Python 3.12+ +- PostgreSQL database +- uv package manager + +### Installation + +1. **Clone the repository** + ```bash + cd /home/cheng/Codes/fst_data_pipeline/fst_data_pipeline/apps/root_db_api + ``` + +2. **Create virtual environment** + ```bash + uv venv + source .venv/bin/activate + ``` + +3. **Install dependencies** + ```bash + uv sync + ``` + +4. **Set up environment variables** + ```bash + cp .env.example .env + # Edit .env with your database configurations + ``` + +5. **Initialize database** + ```bash + # Run database migrations or setup scripts + python -m alembic upgrade head + ``` + +### Running the Application + +#### Development Mode +```bash +python src/app.py +``` + +#### Production Mode +```bash +gunicorn --bind 0.0.0.0:5232 --workers 4 src.app:app +``` + +API服务访问地址: +- **API基础URL**: `http://localhost:5232/api` +- **Swagger UI文档**: `http://localhost:5232/apidocs/` + +## 🔧 Configuration + +### Environment Variables + +Create a `.env` file with the following variables: + +```env +# Database Configuration +DATABASE_URL=postgresql://username:password@localhost:5432/dbname + +# Flask Configuration +FLASK_ENV=development +FLASK_DEBUG=True + +# API Configuration +API_VERSION=0.5.0 + +# Feishu sync switch for version activation (backend) +# Default is off. Set to true only when you want to sync to Feishu. +ENABLE_FEISHU_SYNC=false +``` + +### Database Configuration + +The application uses PostgreSQL with SQLAlchemy. Key models include: +- **Project**: Project information +- **Bag**: Bag file metadata +- **FST**: File system tree nodes +- **Tag**: Data tagging system +- **Topic**: Topic classification +- **GT**: Ground truth data + +## 🧪 Testing + +Run the test suite: + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Run specific test module +pytest src/test/api/test_bags.py -v +``` + +## 📚 API Documentation + +### Swagger/OpenAPI 中文文档 + +交互式中文API文档,当服务器运行时可通过 `/apidocs/` 访问。 + +**主要功能:** +- 🌐 **完整中文界面** - 所有API标签、描述都已中文化 +- 📋 **8大功能模块** - Bag管理、标签管理、FST管理、项目管理、主题管理、几何数据管理、真值数据管理、重计算管理 +- 🔄 **实时测试** - 可直接在文档中测试API接口 +- 📝 **详细示例** - 每个接口都包含完整的请求/响应示例 + +### Example API Calls + +#### Get All Bags +```bash +curl -X GET "http://localhost:5232/api/bags/all" \ + -H "accept: application/json" +``` + +#### Get Pangu Data by Names +```bash +curl -X POST "http://localhost:5232/api/bags/pangu" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "names": [ + "PL061763_event_ld_gps_event_20230807-115627_0.bag.dir" + ] + }' +``` + +#### Search Pangu Data +```bash +curl -X GET "http://localhost:5232/api/bags/search/pangu?project_id=1&limit=10" \ + -H "accept: application/json" +``` + +#### Get FST Tree +```bash +curl -X GET "http://localhost:5232/api/fst/print_tree" \ + -H "accept: application/json" +``` + +#### Update FST Node +```bash +curl -X POST "http://localhost:5232/api/fst/update" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "highway_scenarios", + "description": "Updated highway driving scenarios", + "parent_id": null + }' +``` + +## 🔒 Security + +- Input validation with Pydantic models +- SQL injection protection via SQLAlchemy ORM +- Error handling and logging +- Rate limiting (configure as needed) + +## 📈 Performance + +- Database connection pooling +- Optimized SQL queries +- Configurable Gunicorn workers +- Efficient SQLAlchemy ORM queries + +## 🚀 Deployment + +### Docker Deployment +```bash +# Build Docker image +docker build -t root-db-api . + +# Run container +docker run -p 5232:5232 --env-file .env root-db-api +``` + +### Production Checklist +- [ ] Configure environment variables +- [ ] Set up PostgreSQL database +- [ ] Set up reverse proxy (nginx) +- [ ] Configure logging +- [ ] Set up monitoring +- [ ] Configure SSL/TLS +- [ ] Set up database backups + +## 📝 Development + +### Project Structure +``` +src/ +├── api/ # API接口模块 +│ ├── bags.py # Bag管理API +│ ├── fst.py # FST管理API +│ ├── projects.py # 项目管理API +│ ├── tags.py # 标签管理API +│ ├── topics.py # 主题管理API +│ ├── geometry.py # 几何数据管理API +│ ├── recompute.py # 重计算管理API +│ └── gt.py # 真值数据管理API +├── core/ # Core business logic +│ ├── models.py # Database models +│ └── service.py # Business logic services +├── db/ # Database layer +│ └── connection.py # Database connection +├── test/ # Test files +└── app.py # Application entry point +``` + +### Contributing + +1. Fork the repository +2. Create a feature branch +3. Write tests for new features +4. Ensure all tests pass +5. Submit a pull request + +## 📊 Monitoring + +### Health Check Endpoint +```bash +curl http://localhost:5232/ +# Returns: "Hello World!" +``` + +### Metrics +- Prometheus metrics integration available +- Monitor API response times +- Track database query performance +- Monitor database connection pool usage + +## 🆘 Troubleshooting + +### Common Issues + +1. **Database Connection Error** + - Check PostgreSQL service status + - Verify DATABASE_URL configuration + - Ensure database exists and is accessible + +2. **Import Errors** + - Ensure virtual environment is activated + - Run `uv sync` to install dependencies + +3. **Port Already in Use** + - Change port in app.py or use environment variable + - Kill existing processes on port 5232 + +4. **Geospatial Query Errors** + - Ensure PostGIS extension is installed + - Check spatial data formats and coordinates + +## 📞 Support + +- **Author**: Cheng Li +- **Email**: tbd@tbd.com +- **Version**: 0.5.0 + +## 📄 License + +[Add your license information here] + +--- + +**Happy Coding! 🚀** diff --git a/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/SOURCES.txt b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/SOURCES.txt new file mode 100644 index 0000000..748714d --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/SOURCES.txt @@ -0,0 +1,40 @@ +README.md +pyproject.toml +src/__init__.py +src/app.py +src/api/__init__.py +src/api/bags.py +src/api/fst.py +src/api/geometry.py +src/api/gt.py +src/api/projects.py +src/api/recompute.py +src/api/tags.py +src/api/topics.py +src/api/versions.py +src/core/__init__.py +src/core/feishu_api_constants.py +src/core/feishu_bitable_sdk.py +src/core/feishu_doc_bitable_block_sdk.py +src/core/feishu_wiki_doc_sdk.py +src/core/models.py +src/core/service.py +src/db/__init__.py +src/db/cache.py +src/db/connection.py +src/root_db_api.egg-info/PKG-INFO +src/root_db_api.egg-info/SOURCES.txt +src/root_db_api.egg-info/dependency_links.txt +src/root_db_api.egg-info/requires.txt +src/root_db_api.egg-info/top_level.txt +src/test/__init__.py +src/test/api/__init__.py +src/test/api/test_bags.py +src/test/api/test_fst.py +src/test/api/test_geometry.py +src/test/api/test_projects.py +src/test/api/test_recompute.py +src/test/api/test_tags.py +src/test/api/test_topics.py +src/test/service/__init__.py +src/test/service/test_service.py \ No newline at end of file diff --git a/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/dependency_links.txt b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/requires.txt b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/requires.txt new file mode 100644 index 0000000..d5e5a62 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/requires.txt @@ -0,0 +1,25 @@ +flask>=3.1.1 +flasgger==0.9.7b2 +geoalchemy2==0.17.1 +shapely>=2.1.1 +numpy==2.3.1 +folium==0.20.0 +tenacity==9.1.2 +tqdm==4.67.1 +pytz==2025.2 +prometheus-client==0.22.1 +flask-caching>=2.3.1 +redis>=6.4.0 +cos-python-sdk-v5==1.9.37 +python-dotenv==1.1.1 +requests==2.32.4 +pyyaml==6.0.2 +pydantic==2.11.7 +sqlalchemy==2.0.41 +psycopg2-binary==2.9.10 +openpyxl>=3.1.0 +gunicorn>=23.0.0 +pytest==8.4.1 +pytest-cov==6.2.0 +pytest-mock==3.14.0 +flask-sqlalchemy>=3.1.1 diff --git a/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/top_level.txt b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/top_level.txt new file mode 100644 index 0000000..7c3e612 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/root_db_api.egg-info/top_level.txt @@ -0,0 +1,6 @@ +__init__ +api +app +core +db +test diff --git a/fst_data_pipeline/apps/root_db_api/src/test/__init__.py b/fst_data_pipeline/apps/root_db_api/src/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/__init__.py b/fst_data_pipeline/apps/root_db_api/src/test/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/test_bags.py b/fst_data_pipeline/apps/root_db_api/src/test/api/test_bags.py new file mode 100644 index 0000000..e39c504 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/api/test_bags.py @@ -0,0 +1,343 @@ +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +from fst_data_pipeline.apps.root_db_api.src.api import bags_bp + + +# ------------------ Flask test client ------------------ +@pytest.fixture(scope="module") +def client(): + """Flask test client with bags blueprint registered.""" + app = Flask(__name__) + app.register_blueprint(bags_bp, url_prefix="/bags") + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +# ------------------ 单元测试用例 ------------------ +def test_get_all_bags_200(client, mocker): + bag1 = MagicMock() + bag1.name = "bag1" + bag1.project.name = "ProjectX" + + bag2 = MagicMock() + bag2.name = "bag2" + bag2.project = None + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_bags", + return_value=[bag1, bag2], + ) + + resp = client.get("/bags/all") + assert resp.status_code == 200 + assert resp.get_json() == {"ProjectX": ["bag1"], "Uncategorized": ["bag2"]} + + +def test_search_main_pangu_by_names_200(client, mocker): + p = MagicMock() + p.name = "p1" + p.vehicle = "v1" + p.datetime = "2021-01-01T00:00:00Z" + p.bag_path = "/raw" + p.data_path = "/dec" + p.reserved_str = "rs" + p.reserved_json = {} + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_main_pangu_by_name", + return_value=[p], + ) + + resp = client.post("/bags/pangu", json={"names": ["p1"]}) + assert resp.status_code == 200 + assert resp.get_json()["p1"][0]["vehicle"] == "v1" + + +def test_get_pangu_details_batch_200(client, mocker): + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_pangu_details_by_bag_name", + return_value={"detail": "ok"}, + ) + + resp = client.post("/bags/pangu/detail", json=["a.bag", "b.bag"]) + assert resp.status_code == 200 + assert resp.get_json()["a.bag"]["detail"] == "ok" + + +def test_get_pangu_details_batch_400(client): + resp = client.post("/bags/pangu/detail", json={"wrong": "format"}) + assert resp.status_code == 400 + + +def test_search_main_minerva_by_session_ids_200(client, mocker): + m = MagicMock() + m.session_id = "s1" + m.vin = "VIN1" + m.start_ts = 0 + m.end_ts = 1 + m.platform = "p" + m.path = "/p" + m.converted_path = "/c" + m.gt_path = "/g" + m.datetime = "now" + m.length = 10 + m.reserved_json = {} + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_main_minerva_by_session_id", + return_value=[m], + ) + + resp = client.post("/bags/minerva", json={"session_ids": ["s1"]}) + assert resp.status_code == 200 + assert resp.get_json()["s1"][0]["vin"] == "VIN1" + + +def test_get_minerva_details_batch_200(client, mocker): + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_minerva_details_by_bag_name", + return_value={"session_id": "s1"}, + ) + + resp = client.post("/bags/minerva/detail", json=["a.bag"]) + assert resp.status_code == 200 + assert resp.get_json()["a.bag"]["session_id"] == "s1" + + +def test_get_topics_batch_200(client, mocker): + t = MagicMock() + t.name = "t1" + t.type = "std_msgs/String" + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_topics_by_bag_name", + return_value=[t], + ) + + resp = client.post("/bags/topics", json=["a.bag"]) + assert resp.status_code == 200 + assert resp.get_json() == {"a.bag": [{"name": "t1", "type": "std_msgs/String"}]} + + +def test_get_tags_batch_200(client, mocker): + tag = MagicMock() + tag.name = "tag1" + tag.type = "scenario" + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_tags_by_bag_name", + return_value=[tag], + ) + + resp = client.post("/bags/tags", json=["a.bag"]) + assert resp.status_code == 200 + assert resp.get_json()["a.bag"][0]["name"] == "tag1" + + +def test_get_fst_nodes_batch_200(client, mocker): + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_fst_nodes_by_bag_name", + return_value=[{"name": "n1", "start": 0, "end": 10}], + ) + + resp = client.post("/bags/fst/nodes", json=["a.bag"]) + assert resp.status_code == 200 + assert resp.get_json()["a.bag"][0]["name"] == "n1" + + +def test_get_bag_lifecycle_200(client, mocker): + lc = MagicMock() + lc.bag_name = "a.bag" + lc.collect_time = "2021-01-01T00:00:00Z" + lc.clone2dev_time = "2021-01-01T01:00:00Z" + lc.decode_time = "2021-01-01T02:00:00Z" + lc.mining_time = "2021-01-01T03:00:00Z" + lc.auto_annotate_time = "2021-01-01T04:00:00Z" + lc.manual_annotate_time = "2021-01-01T05:00:00Z" + lc.fst_index_time = "2021-01-01T06:00:00Z" + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_bag_lifecycle_by_bag_names", + return_value=[lc], + ) + + resp = client.post("/bags/life_cycle", json={"bag_names": ["a.bag"]}) + assert resp.status_code == 200 + assert resp.get_json()["a.bag"]["collect_time"] == "2021-01-01T00:00:00Z" + + +# ------------------ 400 分支示例 ------------------ +@pytest.mark.parametrize( + "url,bad_body", + [ + ("/bags/pangu/detail", {"not": "list"}), + ("/bags/minerva/detail", {"not": "list"}), + ("/bags/topics", {"bag_names": "not_list"}), + ("/bags/tags", {"bag_names": "not_list"}), + ("/bags/fst/nodes", "not_list"), + ], +) +def test_bad_body_400(client, url, bad_body): + resp = client.post(url, json=bad_body) + assert resp.status_code == 400 + + +def test_search_pangu_basic_200(client, mocker): + """测试基本的pangu搜索功能""" + p = MagicMock() + p.name = "test_bag.bag" + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_main_pangu_by_conditions", + return_value=[p], + ) + + resp = client.post("/bags/search/pangu", json={}) + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, list) + assert data == ["test_bag.bag"] + + +def test_search_pangu_with_all_params_200(client, mocker): + """测试带所有参数的pangu搜索""" + p1 = MagicMock() + p1.name = "test_bag1.bag" + p2 = MagicMock() + p2.name = "test_bag2.bag" + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_main_pangu_by_conditions", + return_value=[p1, p2], + ) + + request_data = { + "starttime": "20240101", + "endtime": "20241231", + "vehicle": "test_vehicle", + "keyword": "highway", + "topics": ["/camera/front", "/lidar/points"], + "fst": "highway_scenarios", + } + + resp = client.post("/bags/search/pangu", json=request_data) + assert resp.status_code == 200 + data = resp.get_json() + assert data == ["test_bag1.bag", "test_bag2.bag"] + + +def test_search_pangu_invalid_date_format_400(client): + """测试日期格式错误""" + request_data = {"starttime": "invalid_date"} + + resp = client.post("/bags/search/pangu", json=request_data) + assert resp.status_code == 400 + data = resp.get_json() + assert "error" in data + + +def test_search_pangu_invalid_topics_400(client): + """测试topics参数格式错误""" + request_data = {"topics": "not_a_list"} + + resp = client.post("/bags/search/pangu", json=request_data) + assert resp.status_code == 400 + data = resp.get_json() + assert "topics must be an array" in data["error"] + + +def test_search_pangu_empty_result_200(client, mocker): + """测试搜索无结果""" + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.get_main_pangu_by_conditions", + return_value=[], + ) + + resp = client.post("/bags/search/pangu", json={"keyword": "nonexistent"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data == [] + + +def test_merge_bags_query_specific_bags_200(client, mocker): + """测试查询指定bag的合并关系""" + mock_result = { + "parent1.bag": ["child1.bag", "child2.bag"], + "parent2.bag": ["child3.bag"], + } + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.query_existed_joined_bag", + return_value=mock_result, + ) + + request_data = {"bag_names": ["parent1.bag", "parent2.bag"]} + + resp = client.post("/bags/joined/query", json=request_data) + assert resp.status_code == 200 + data = resp.get_json() + assert data == mock_result + + +def test_merge_bags_query_wildcard_200(client, mocker): + """测试通配符查询所有合并关系""" + mock_result = { + "parent1.bag": ["child1.bag", "child2.bag"], + "parent2.bag": ["child3.bag", "child4.bag"], + "parent3.bag": ["child5.bag"], + } + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.query_all_joined_bags", + return_value=mock_result, + ) + + request_data = {"bag_names": ["*"]} + + resp = client.post("/bags/joined/query", json=request_data) + assert resp.status_code == 200 + data = resp.get_json() + assert data == mock_result + + +def test_merge_bags_query_empty_bag_names_400(client): + """测试空的bag_names参数""" + resp = client.post("/bags/joined/query", json={}) + assert resp.status_code == 400 + data = resp.get_json() + assert "bag_names must be a list" in data["error"] + + +def test_merge_bags_query_invalid_bag_names_400(client): + """测试无效的bag_names参数格式""" + resp = client.post("/bags/joined/query", json={"bag_names": "not_a_list"}) + assert resp.status_code == 400 + data = resp.get_json() + assert "bag_names must be a list" in data["error"] + + +def test_merge_bags_query_empty_list_400(client): + """测试空列表的bag_names参数 - 现在应该返回200因为代码已经改变""" + resp = client.post("/bags/joined/query", json={"bag_names": []}) + assert resp.status_code == 400 + data = resp.get_json() + assert "bag_names must be a list" in data["error"] + + +def test_merge_bags_query_exception_500(client, mocker): + """测试内部异常处理""" + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.bags.query_existed_joined_bag", + side_effect=Exception("Database error"), + ) + + request_data = {"bag_names": ["test.bag"]} + + resp = client.post("/bags/joined/query", json=request_data) + assert resp.status_code == 500 + data = resp.get_json() + assert "Internal server error" in data["error"] diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/test_fst.py b/fst_data_pipeline/apps/root_db_api/src/test/api/test_fst.py new file mode 100644 index 0000000..5f23f94 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/api/test_fst.py @@ -0,0 +1,233 @@ +# tests/test_fst.py +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + +from fst_data_pipeline.apps.root_db_api.src.api import fst_bp + + +@pytest.fixture(scope="module") +def app(): + _app = Flask(__name__) + _app.register_blueprint(fst_bp, url_prefix="/fst") + _app.config["TESTING"] = True + return _app + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture(autouse=True) +def mock_db_session(): + """ + 全局自动 mock SessionLocal,避免每个测试都手动 patch。 + 返回一个 mock session 实例,可以在测试中进一步断言。 + """ + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.SessionLocal", + return_value=MagicMock(), + ) as mocked: + yield mocked + + +# ----------------- 1. 打印树 ----------------- +def test_print_tree_200(client: pytest.fixture): + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.print_fst_tree", + return_value="fake_tree", + ): + resp = client.get("/fst/print_tree") + + assert resp.status_code == 200 + assert resp.content_type == "application/json" + assert resp.get_json() == "fake_tree" + + +# ----------------- 2. 批量根据 FST 名称查 Bags ----------------- +def test_get_bags_by_fst_nodes_batch_200(client: pytest.fixture): + # 构造 Bag 假对象 + Bag = type("Bag", (), {"name": None}) + fst1_bags = [Bag(), Bag()] + fst1_bags[0].name, fst1_bags[1].name = "b1", "b2" + fst2_bags = [Bag()] + fst2_bags[0].name = "b3" + + side_effect = {"fst1": fst1_bags, "fst2": fst2_bags} + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_bags_by_fst_name", + side_effect=lambda db, name: side_effect[name], + ): + # 默认第 1 页,每页 20 + resp = client.post("/fst/bags/nodes", json=["fst1", "fst2"]) + + assert resp.status_code == 200 + data = resp.get_json() + + # 期望 3 条记录,不分页 + assert data["page"] == 1 + assert data["per_page"] == 20 + assert data["total"] == 3 + + # 顺序与插入顺序一致 + assert data["items"] == [ + {"bag_name": "b1", "sts": "fst1"}, + {"bag_name": "b2", "sts": "fst1"}, + {"bag_name": "b3", "sts": "fst2"}, + ] + + +def test_get_bags_by_fst_nodes_batch_400(client: pytest.fixture): + resp = client.post("/fst/bags/nodes", json={"wrong": "format"}) + assert resp.status_code == 400 + assert "Body must be a list" in resp.get_json()["error"] + + +# ----------------- 3. 根据路径查 Bags ----------------- +def test_get_bags_by_fst_path_200(client: pytest.fixture): + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_bags_by_fst_paths", + return_value={"fst": {"bags": ["bagA", "bagB"]}}, + ): + resp = client.get("/fst/bags/path/fst") + assert resp.status_code == 200 + assert resp.get_json() == {"fst": {"bags": ["bagA", "bagB"]}} + + +# ----------------- 4. 根据名称查 FST ----------------- +def test_get_fst_node_by_name_200(client: pytest.fixture): + fst_mock = MagicMock() + fst_mock.name = "node" + fst_mock.parent_id = 123 + + parent_mock = MagicMock() + parent_mock.name = "parent_node" + + with ( + patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_fst", + return_value=fst_mock, + ), + patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_fst_by_id", + return_value=parent_mock, + ), + patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_total_bag_sum", + return_value=42, + ), + ): + resp = client.get("/fst/node") + assert resp.status_code == 200 + assert resp.get_json() == { + "name": "node", + "parent_node": "parent_node", + "linked_bags_sum": 42, + } + + +def test_get_fst_node_by_name_404(client: pytest.fixture): + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_fst", return_value=None + ): + resp = client.get("/fst/nonexistent") + assert resp.status_code == 404 + + +def test_update_fst_bag__invalid_json(client: pytest.fixture): + resp = client.post("/fst/bags/update", json={"not": "list"}) + assert resp.status_code == 400 + + +# ----------------- 7. 创建/更新 FST 节点 ----------------- +def test_upsert_fst__create_success(client: pytest.fixture): + node_mock = MagicMock() + node_mock.id = 99 + node_mock.name = "new_node" + node_mock.parent_id = None + node_mock.reserved_json = {"foo": "bar"} + node_mock.bag_sum = 0 + + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.upsert_fst_node", + return_value=node_mock, + ): + resp = client.post( + "/fst/update", + json={ + "name": "new_node", + "parent_name": None, + "reserved_json": {"foo": "bar"}, + }, + ) + assert resp.status_code == 200 + assert resp.get_json()["name"] == "new_node" + + +def test_upsert_fst__400_missing_name(client: pytest.fixture): + resp = client.post("/fst/update", json={"parent_name": "root"}) + assert resp.status_code == 400 + assert "Field 'name' is required" in resp.get_json()["error"] + + +# ----------------- 8. 删除 FST 节点 ----------------- +def test_delete_fst_by_name__success(client: pytest.fixture): + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.delete_fst_by_fst_name" + ) as mock_delete: + resp = client.delete("/fst/some_node") + mock_delete.assert_called_once() + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True} + + +def test_delete_fst_by_name__404(client: pytest.fixture): + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.delete_fst_by_fst_name", + side_effect=ValueError("not found"), + ): + resp = client.delete("/fst/nonexistent") + assert resp.status_code == 404 + assert "not found" in resp.get_json()["error"] + + +# ----------------- 9. 获取 FST 树形结构与 bag 详情 ----------------- +def test_list_bag_names_with_fst__with_bags(client: pytest.fixture): + # Mock返回数据库行格式 - 每行的第一列是bag名称 + mock_rows = [ + ("sample_03.bag", "other_data"), + ("sample_01.bag", "other_data"), + ("sample_02.bag", "other_data"), + ("sample_01.bag", "other_data"), # 重复项,测试去重功能 + ] + + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_bag_names_linked_to_fst", + return_value=mock_rows, + ): + resp = client.get("/fst/baglist") + + assert resp.status_code == 200 + data = resp.get_json() + + # 验证返回的是排序后的bag名称列表(去重) + assert isinstance(data, list) + assert data == ["sample_01.bag", "sample_02.bag", "sample_03.bag"] + + +def test_list_bag_names_with_fst__empty_list(client: pytest.fixture): + # Mock返回空的数据库结果 + with patch( + "fst_data_pipeline.apps.root_db_api.src.api.fst.get_bag_names_linked_to_fst", + return_value=[], + ): + resp = client.get("/fst/baglist") + + assert resp.status_code == 200 + data = resp.get_json() + + # 验证返回空列表 + assert isinstance(data, list) + assert data == [] diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/test_geometry.py b/fst_data_pipeline/apps/root_db_api/src/test/api/test_geometry.py new file mode 100644 index 0000000..9f4c584 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/api/test_geometry.py @@ -0,0 +1,61 @@ +# tests/test_geometry.py +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +from fst_data_pipeline.apps.root_db_api.src.api import geometry_bp + + +@pytest.fixture(scope="module") +def client(): + """Flask test client with geometry blueprint registered.""" + app = Flask(__name__) + app.register_blueprint(geometry_bp, url_prefix="/geometry") + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +def test_get_geometry_info_by_rosbags_200(client, mocker): + """200 OK:查询到多条几何记录""" + # 1. 伪造查询结果 + fake_rows = [ + MagicMock( + rosbag_name="bag1", lon_lat_alt=[[12.34, 56.78, 0.0], [12.35, 56.79, 1.2]] + ), + MagicMock(rosbag_name="bag2", lon_lat_alt=[[98.76, 54.32, 3.3]]), + ] + + # 2. 拦截 SessionLocal 返回的 session 对象 + mock_session = MagicMock() + mock_session.execute.return_value.fetchall.return_value = fake_rows + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.geometry.SessionLocal", + return_value=mock_session, + ) + + resp = client.post("/geometry/", json={"rosbag_names": ["bag1", "bag2"]}) + assert resp.status_code == 200 + data = resp.get_json() + assert data["bag1"] == [[12.34, 56.78, 0.0], [12.35, 56.79, 1.2]] + assert data["bag2"] == [[98.76, 54.32, 3.3]] + + +def test_get_geometry_info_by_rosbags_400(client, mocker): + """400 空列表或格式错误""" + resp = client.post("/geometry/", json={"rosbag_names": []}) + assert resp.status_code == 400 + + +def test_get_geometry_info_by_rosbags_404(client, mocker): + """404 找不到任何记录""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchall.return_value = [] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.geometry.SessionLocal", + return_value=mock_session, + ) + + resp = client.post("/geometry/", json={"rosbag_names": ["nonexistent"]}) + assert resp.status_code == 404 diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/test_projects.py b/fst_data_pipeline/apps/root_db_api/src/test/api/test_projects.py new file mode 100644 index 0000000..b3ee58e --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/api/test_projects.py @@ -0,0 +1,27 @@ +# tests/test_projects.py +import pytest +from flask import Flask + + +@pytest.fixture(scope="module") +def client(): + from fst_data_pipeline.apps.root_db_api.src.api import api_bp + + app = Flask(__name__) + app.register_blueprint(api_bp, url_prefix="/api") + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +# tests/test_projects.py +def test_read_projects_200(client, mocker): + fake = [{"id": 1, "name": "Alpha"}, {"id": 2, "name": "Beta"}] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.projects.get_projects", + return_value=fake, + ) + + resp = client.get("/api/projects/all") + assert resp.status_code == 200 + assert resp.get_json() == [{"id": 1, "name": "Alpha"}, {"id": 2, "name": "Beta"}] diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/test_recompute.py b/fst_data_pipeline/apps/root_db_api/src/test/api/test_recompute.py new file mode 100644 index 0000000..e001eb5 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/api/test_recompute.py @@ -0,0 +1,185 @@ +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +from fst_data_pipeline.apps.root_db_api.src.api.recompute import bp as recompute_bp + + +# ------------------ Flask test client ------------------ +@pytest.fixture(scope="module") +def client(): + """Flask test client with recompute blueprint registered.""" + app = Flask(__name__) + app.register_blueprint(recompute_bp, url_prefix="/recompute") + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +# ------------------ 单元测试用例 ------------------ +def test_get_bag_versions_200(client, mocker): + """测试批量获取bag版本列表 - 成功场景""" + mock_result = { + "bag1.bag": ["v1.0", "v1.1", "v2.0"], + "bag2.bag": ["v1.0", "v2.0"], + "bag3.bag": [], + } + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.recompute.get_bag_recompute_versions", + return_value=mock_result, + ) + mocker.patch("fst_data_pipeline.apps.root_db_api.src.api.recompute.SessionLocal") + + resp = client.post( + "/recompute/versions", json={"bag_names": ["bag1.bag", "bag2.bag", "bag3.bag"]} + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["bag1.bag"] == ["v1.0", "v1.1", "v2.0"] + assert data["bag2.bag"] == ["v1.0", "v2.0"] + assert data["bag3.bag"] == [] + + +def test_get_bag_versions_400_no_bag_names(client): + """测试批量获取bag版本列表 - 缺少bag_names""" + resp = client.post("/recompute/versions", json={}) + assert resp.status_code == 400 + assert "bag_names list required" in resp.get_json()["error"] + + +def test_get_bag_versions_400_empty_bag_names(client): + """测试批量获取bag版本列表 - bag_names为空列表""" + resp = client.post("/recompute/versions", json={"bag_names": []}) + assert resp.status_code == 400 + assert "bag_names must be a non-empty list" in resp.get_json()["error"] + + +def test_get_bag_versions_400_invalid_bag_names(client): + """测试批量获取bag版本列表 - bag_names不是列表""" + resp = client.post("/recompute/versions", json={"bag_names": "not_a_list"}) + assert resp.status_code == 400 + assert "bag_names must be a non-empty list" in resp.get_json()["error"] + + +def test_get_bag_versions_500_exception(client, mocker): + """测试批量获取bag版本列表 - 服务器异常""" + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.recompute.get_bag_recompute_versions", + side_effect=Exception("Database error"), + ) + mocker.patch("fst_data_pipeline.apps.root_db_api.src.api.recompute.SessionLocal") + + resp = client.post("/recompute/versions", json={"bag_names": ["bag1.bag"]}) + assert resp.status_code == 500 + assert "Database error" in resp.get_json()["error"] + + +def test_get_storage_paths_200(client, mocker): + """测试批量获取存储路径 - 成功场景""" + mock_result = [ + { + "bag_name": "bag1.bag", + "recompute_version": "v1.0", + "results": [ + { + "result_id": 1, + "bag_name": "bag1.bag", + "recompute_version": "v1.0", + "result_type": "perception", + "storage_path": "/path/to/result1", + "status": "available", + "created_time": "2024-01-16T10:30:00Z", + "reserved_json": {}, + } + ], + } + ] + + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.recompute.get_storage_paths_batch", + return_value=mock_result, + ) + mocker.patch("fst_data_pipeline.apps.root_db_api.src.api.recompute.SessionLocal") + + requests = [{"bag_name": "bag1.bag", "recompute_version": "v1.0"}] + resp = client.post("/recompute/storage", json={"requests": requests}) + assert resp.status_code == 200 + data = resp.get_json() + assert len(data) == 1 + assert data[0]["bag_name"] == "bag1.bag" + assert data[0]["recompute_version"] == "v1.0" + assert len(data[0]["results"]) == 1 + assert data[0]["results"][0]["result_type"] == "perception" + + +def test_get_storage_paths_400_no_requests(client): + """测试批量获取存储路径 - 缺少requests""" + resp = client.post("/recompute/storage", json={}) + assert resp.status_code == 400 + assert "requests list required" in resp.get_json()["error"] + + +def test_get_storage_paths_400_empty_requests(client): + """测试批量获取存储路径 - requests为空列表""" + resp = client.post("/recompute/storage", json={"requests": []}) + assert resp.status_code == 400 + assert "requests must be a non-empty list" in resp.get_json()["error"] + + +def test_get_storage_paths_400_invalid_requests(client): + """测试批量获取存储路径 - requests不是列表""" + resp = client.post("/recompute/storage", json={"requests": "not_a_list"}) + assert resp.status_code == 400 + assert "requests must be a non-empty list" in resp.get_json()["error"] + + +def test_get_storage_paths_400_invalid_request_format(client): + """测试批量获取存储路径 - 单个request格式错误""" + invalid_requests = [ + {"bag_name": "bag1.bag"}, # 缺少recompute_version + {"recompute_version": "v1.0"}, # 缺少bag_name + {"wrong": "format"}, # 完全错误的格式 + ] + + for invalid_request in invalid_requests: + resp = client.post("/recompute/storage", json={"requests": [invalid_request]}) + assert resp.status_code == 400 + assert ( + "Each request must contain bag_name and recompute_version" + in resp.get_json()["error"] + ) + + +def test_get_storage_paths_500_exception(client, mocker): + """测试批量获取存储路径 - 服务器异常""" + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.recompute.get_storage_paths_batch", + side_effect=Exception("Service error"), + ) + mocker.patch("fst_data_pipeline.apps.root_db_api.src.api.recompute.SessionLocal") + + requests = [{"bag_name": "bag1.bag", "recompute_version": "v1.0"}] + resp = client.post("/recompute/storage", json={"requests": requests}) + assert resp.status_code == 500 + assert "Service error" in resp.get_json()["error"] + + +# ------------------ 参数化测试 ------------------ +@pytest.mark.parametrize( + "endpoint,bad_body", + [ + ("/recompute/versions", {"bag_names": "not_list"}), + ("/recompute/versions", {"bag_names": []}), + ("/recompute/versions", {}), + ("/recompute/storage", {"requests": "not_list"}), + ("/recompute/storage", {"requests": []}), + ("/recompute/storage", {}), + ("/recompute/storage", {"requests": [{"invalid": "format"}]}), + ], +) +def test_bad_requests_400(client, endpoint, bad_body): + """参数化测试:各种错误请求格式""" + resp = client.post(endpoint, json=bad_body) + assert resp.status_code == 400 diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/test_tags.py b/fst_data_pipeline/apps/root_db_api/src/test/api/test_tags.py new file mode 100644 index 0000000..d185e30 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/api/test_tags.py @@ -0,0 +1,113 @@ +# tests/test_tags.py +import pytest +from flask import Flask + +from fst_data_pipeline.apps.root_db_api.src.api.tags import bp as tags_bp + + +@pytest.fixture(scope="module") +def client(): + """Flask test client with tags blueprint registered.""" + app = Flask(__name__) + app.register_blueprint(tags_bp, url_prefix="/tags") + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +# ---------- /tags/all ---------- +def test_read_tags_200(client, mocker): + fake_tags = [ + {"name": "tag1", "type": "scenario", "creator": "alice"}, + {"name": "tag2", "type": "label", "creator": "bob"}, + ] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.tags.get_tags", + return_value=fake_tags, + ) + + resp = client.get("/tags/all") + assert resp.status_code == 200 + assert resp.get_json() == fake_tags + + +# ---------- /tags/bags/ ---------- +def test_get_bags_by_tags_and_200(client, mocker): + fake_bags = [{"id": 1, "name": "foo.bag"}, {"id": 2, "name": "bar.bag"}] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.tags.query_bags_by_tags", + return_value=fake_bags, + ) + + resp = client.get("/tags/bags?tags=foo,bar&op=and") + assert resp.status_code == 200 + assert resp.get_json() == {"bags": fake_bags} + + +def test_get_bags_by_tags_or_200(client, mocker): + fake_bags = [{"id": 3, "name": "baz.bag"}] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.tags.query_bags_by_tags", + return_value=fake_bags, + ) + + resp = client.get("/tags/bags?tags=baz&op=or") + assert resp.status_code == 200 + assert resp.get_json() == {"bags": fake_bags} + + +def test_get_bags_by_tags_default_op_and(client, mocker): + """不传 op 时默认按 and 处理""" + fake_bags = [{"id": 4, "name": "default.bag"}] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.tags.query_bags_by_tags", + return_value=fake_bags, + ) + + resp = client.get("/tags/bags?tags=default") # 不带 op 参数 + assert resp.status_code == 200 + assert resp.get_json() == {"bags": fake_bags} + + +@pytest.mark.parametrize("bad_tags", ["", " "]) +def test_get_bags_by_tags_missing_tags_param(client, bad_tags): + resp = client.get(f"/tags/bags?tags={bad_tags}") + assert resp.status_code == 400 + assert resp.get_json() == {"bags": []} + + +def test_get_bags_by_tags_invalid_op(client): + resp = client.get("/tags/bags?tags=A,B&op=invalid") + assert resp.status_code == 400 + assert resp.get_json() == {"bags": []} + + +# ---------- /tags/creators/ ---------- +def test_get_tags_by_creator_200(client, mocker): + fake_tags = [ + {"name": "tagA", "type": "quality", "creator": "alice"}, + {"name": "tagB", "type": "label", "creator": "alice"}, + ] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.tags.get_tags_by_creator", + return_value=fake_tags, + ) + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.tags.SessionLocal", + return_value=mocker.MagicMock(), + ) + + resp = client.get("/tags/creators/alice") + assert resp.status_code == 200 + assert resp.get_json() == {"tags": ["tagA", "tagB"]} + + +def test_get_tags_by_creator_404(client, mocker): + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.tags.get_tags_by_creator", + return_value=[], + ) + + resp = client.get("/tags/creators/nobody") + assert resp.status_code == 200 + assert resp.get_json() == {"tags": []} diff --git a/fst_data_pipeline/apps/root_db_api/src/test/api/test_topics.py b/fst_data_pipeline/apps/root_db_api/src/test/api/test_topics.py new file mode 100644 index 0000000..343d3ab --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/api/test_topics.py @@ -0,0 +1,53 @@ +# tests/test_topics.py +import pytest +from flask import Flask + +from fst_data_pipeline.apps.root_db_api.src.api.topics import bp as topics_bp + + +@pytest.fixture(scope="module") +def client(): + """Flask test client with topics blueprint registered.""" + app = Flask(__name__) + app.register_blueprint(topics_bp, url_prefix="/topics") + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +# ---------- /topics/all ---------- +def test_read_topics_200(client, mocker): + fake_topics = [ + {"name": "topic1", "type": "scenario"}, + {"name": "topic2", "type": "label"}, + ] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.topics.get_all_topics", + return_value=fake_topics, + ) + resp = client.get("/topics/all") + assert resp.status_code == 200 + assert resp.get_json() == fake_topics + + +# ---------- /topics/bags/ ---------- +def test_get_bags_by_topic_200(client, mocker): + fake_bags = [{"name": "bagA"}, {"name": "bagB"}] + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.topics.get_bags_by_topic_name", + return_value=fake_bags, + ) + resp = client.get("/topics/bags/highway") + assert resp.status_code == 200 + assert resp.get_json() == {"name": ["bagA", "bagB"]} + + +def test_get_bags_by_topic_empty(client, mocker): + mocker.patch( + "fst_data_pipeline.apps.root_db_api.src.api.topics.get_bags_by_topic_name", + return_value=[], + ) + + resp = client.get("/topics/bags/unknown") + assert resp.status_code == 200 + assert resp.get_json() == {"name": []} diff --git a/fst_data_pipeline/apps/root_db_api/src/test/service/__init__.py b/fst_data_pipeline/apps/root_db_api/src/test/service/__init__.py new file mode 100644 index 0000000..31df994 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/service/__init__.py @@ -0,0 +1 @@ +# Test service package diff --git a/fst_data_pipeline/apps/root_db_api/src/test/service/test_service.py b/fst_data_pipeline/apps/root_db_api/src/test/service/test_service.py new file mode 100644 index 0000000..a0248bf --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/test/service/test_service.py @@ -0,0 +1,703 @@ +# tests/test_services_mock_only.py +from unittest.mock import MagicMock, patch + +import pytest + +# 把要测试的 service 函数全部一次性 import +import fst_data_pipeline.apps.root_db_api.src.core.service as srv + + +@pytest.fixture +def db(): + """返回一个 mock Session,所有测试函数共用""" + return MagicMock() + + +# ---------- Project ---------- +def test_get_projects(db): + p1 = MagicMock() + p1.id, p1.name = 1, "proj1" + + p2 = MagicMock() + p2.id, p2.name = 2, "proj2" + + db.query().all.return_value = [p1, p2] + assert srv.get_projects(db) == [ + {"id": 1, "name": "proj1"}, + {"id": 2, "name": "proj2"}, + ] + + +# ---------- Bag ---------- +def test_get_bags(db): + bag = MagicMock() + bag.name = "bag1" + bag.project = MagicMock() + bag.project.name = "p1" + + db.query().options().all.return_value = [bag] + assert srv.get_bags(db) == [bag] + + +def test_get_bags_by_conditions(db): + q = db.query.return_value + q.filter.return_value = q + bag = MagicMock() + bag.name = "foo" + q.all.return_value = [bag] + + res = srv.get_bags_by_conditions(db, name="bar", project_id=1) + assert res == [bag] + # 只要 filter 被调用了两次即可 + assert q.filter.call_count == 2 + + +# ---------- Topic ---------- +def test_get_all_topics(db): + t = MagicMock() + t.name, t.type = "/imu", "sensor_msgs/Imu" + db.query().all.return_value = [t] + assert srv.get_all_topics(db) == [{"name": "/imu", "type": "sensor_msgs/Imu"}] + + +def test_get_bags_by_topic_name(db): + bag = MagicMock() + bag.name = "bag1" + db.query().join().join().filter().all.return_value = [bag] + assert srv.get_bags_by_topic_name(db, "imu") == [{"name": "bag1"}] + + +def test_get_topics_by_bag_name(db): + topic = MagicMock() + topic.name = "/gps" + topic.type = "sensor_msgs/NavSatFix" + db.query().join().join().filter().all.return_value = [topic] + assert srv.get_topics_by_bag_name(db, "bag1") == [topic] + + +# ---------- Tag ---------- +def test_get_tags(db): + tag = MagicMock() + tag.name, tag.type, tag.creator = "tag1", "bool", "Alice" + db.query().all.return_value = [tag] + assert srv.get_tags(db) == [{"name": "tag1", "type": "bool", "creator": "Alice"}] + + +def test_get_tags_by_creator(db): + tag = MagicMock() + tag.name = "t1" + tag.type = "bool" + tag.creator = "alice" + db.query().filter().all.return_value = [tag] + assert srv.get_tags_by_creator(db, "alice") == [ + {"name": "t1", "type": "bool", "creator": "alice"}, + ] + + +def test_get_bags_by_tag_name(db): + bag = MagicMock() + bag.name = "b1" + db.query().join().join().filter().all.return_value = [bag] + assert srv.get_bags_by_tag_name(db, "hot") == [{"name": "b1"}] + + +def test_get_tags_by_bag_name(db): + tag = MagicMock() + tag.name = "urgent" + db.query().join().join().filter().all.return_value = [tag] + assert srv.get_tags_by_bag_name(db, "bag99") == [tag] + + +# ---------- MainPangu ---------- +def test_get_main_pangu_by_conditions(db): + from datetime import date + + q = db.query.return_value + q.filter.return_value = q + q.join.return_value = q # Add join mock for BagList join + q.limit.return_value = q + rec = MagicMock() + rec.keyword = "pangu1" + rec.vehicle = "Lexus" + rec.start_time = "20250101" + rec.end_time = "20250102" + q.all.return_value = [rec] + + res = srv.get_main_pangu_by_conditions( + db, + start_time=date(2025, 1, 1), # Use date objects + end_time=date(2025, 1, 2), + vehicle="Lexus", + keyword="pangu1", + topics=None, # Add new parameters + fst=None, + limit=1000, + ) + assert res == [rec] + + +def test_get_main_pangu_by_name(db): + rec = MagicMock() + rec.name = "pangu_2024" + db.query().filter().all.return_value = [rec] + assert srv.get_main_pangu_by_name(db, "2024") == [rec] + + +def test_get_pangu_details_by_bag_name(db): + main = MagicMock() + main.name = "bag1" + main.vehicle = "Lexus" + + bag = MagicMock() + bag.id = 7 + bag.foo = "bar" + + sec = MagicMock() + sec.extra = "meta" + + db.query().filter().first.side_effect = [main, bag, sec] + res = srv.get_pangu_details_by_bag_name(db, "bag1") + assert res["bag_name"] == "bag1" + assert res["vehicle"] == "Lexus" + assert res["foo"] == "bar" + assert res["extra"] == "meta" + + +# ---------- MainMinerva ---------- +def test_get_main_minerva_by_session_id(db): + rec = MagicMock() + rec.session_id = "session123" + db.query().filter().all.return_value = [rec] + assert srv.get_main_minerva_by_session_id(db, "123") == [rec] + + +def test_get_minerva_details_by_bag_name(db): + main = MagicMock() + main.session_id = "s1" + main.vin = "VIN001" + + bag = MagicMock() + bag.id = 5 + + sec = MagicMock() + sec.meta = "data" + + db.query().filter().first.side_effect = [main, bag, sec] + res = srv.get_minerva_details_by_bag_name(db, "s1") + assert res["bag_name"] == "s1" + assert res["vin"] == "VIN001" + assert res["meta"] == "data" + + +# ---------- BagLifecycle ---------- +def test_get_bag_lifecycle_by_bag_names(db): + lc = MagicMock() + lc.bag_name = "b1" + db.query().filter().all.return_value = [lc] + assert srv.get_bag_lifecycle_by_bag_names(db, ["b1"]) == [lc] + + +# ---------- GeometryInfo ---------- +def test_get_geometry_info(db): + geom = MagicMock() + db.query().all.return_value = [geom] + assert srv.get_geometry_info(db) == [geom] + + +def test_get_geometry_info_by_rosbag_name(db): + db.query().filter().first.return_value = "geom_found" + assert srv.get_geometry_info_by_rosbag_name(db, "rosbag1") == "geom_found" + + +# ---------- FST ---------- +def test_get_fst(db): + fst = MagicMock() + fst.name = "root" + db.query().filter().first.return_value = fst + assert srv.get_fst(db, "root").name == "root" + + +# ---------- FST ---------- +def test_get_total_bag_sum(db): + q = MagicMock() + db.query.return_value = q + q.filter.return_value = q + q.scalar.return_value = 42 + assert srv.get_total_bag_sum(db, "root") == 42 + + +def test_get_fst_by_id(db): + fst = MagicMock() + fst.id = 99 + db.query().filter().first.return_value = fst + assert srv.get_fst_by_id(db, 99).id == 99 + + +def test_get_fst_nodes_by_bag_name(db): + row = MagicMock() + row.name, row.start, row.end, row.comments, row.video_url, row.img_url = ( + "fstA", + 10.0, + 20.0, + "test", + "video", + "img", + ) + db.query().select_from().join().join().filter().all.return_value = [row] + res = srv.get_fst_nodes_by_bag_name(db, "bag1") + assert res == [ + { + "name": "fstA", + "start": "10.0s", + "end": "20.0s", + "comments": "test", + "video_url": "video", + "img_url": "img", + } + ] + + +def test_get_fst_path_by_bag_name(db): + # 链:child -> parent -> root + child = MagicMock() + child.id, child.name, child.parent_id = 3, "child", 2 + + parent = MagicMock() + parent.id, parent.name, parent.parent_id = 2, "parent", 1 + + root = MagicMock() + root.id, root.name, root.parent_id = 1, "root", None + + bag = MagicMock() + bag.fst_node_id = 3 + + db.query().filter().first.side_effect = [bag, child, parent, root, None] + path = srv.get_fst_path_by_bag_name(db, "bag1") + expected = [ + {"id": 3, "name": "child"}, + {"id": 2, "name": "parent"}, + {"id": 1, "name": "root"}, + ] + assert path == expected + + +def test_get_bags_by_fst_name(db): + bag = MagicMock() + bag.name = "bag_fst" + db.query().join().join().filter().all.return_value = [bag] + assert srv.get_bags_by_fst_name(db, "fst") == [bag] + + +# ---------- query_bags_by_tags ---------- +def test_query_bags_by_tags_empty_tag_list(db): + """空标签列表立即返回空列表""" + res = srv.query_bags_by_tags(db, []) + assert res == [] + # 确保没有任何查询被调用 + db.query.assert_not_called() + + +def test_query_bags_by_tags_and_op(db): + """AND 逻辑:bag 必须同时关联全部标签""" + # 1. 先 mock 标签查询:假设前端传了 ["t1", "t2"],库里有这两个标签 + tag1, tag2 = MagicMock(), MagicMock() + tag1.id, tag2.id = 11, 22 + db.query.return_value.filter.return_value.all.return_value = [(11,), (22,)] + + # 2. 再 mock bag 查询:只返回一个 bag 名 "bag_ok" + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.group_by.return_value = query_mock + query_mock.having.return_value = query_mock + query_mock.all.return_value = [("bag_ok",)] + + res = srv.query_bags_by_tags(db, ["t1", "t2"], op="and") + assert res == ["bag_ok"] + + having_call = query_mock.having.call_args[0][0] + assert query_mock.having.call_count == 1 + + +def test_query_bags_by_tags_or_op(db): + """OR 逻辑:bag 只要关联任一标签即可""" + # 1. 标签查询同上 + db.query.return_value.filter.return_value.all.return_value = [(11,), (22,)] + + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.distinct.return_value = query_mock + query_mock.all.return_value = [("bag_a",), ("bag_b",)] + + res = srv.query_bags_by_tags(db, ["t1", "t2"], op="or") + assert res == ["bag_a", "bag_b"] + + # 验证调用了 distinct + assert query_mock.distinct.call_count == 1 + + # ---------- get_bag_names_linked_to_fst ---------- + def test_get_bag_names_linked_to_fst_ok(db): + """成功场景:返回去重且按名字排序的 bag 名列表""" + # 模拟 query 链式调用 + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.order_by.return_value = query_mock + # distinct 返回的是子查询对象,继续链式 + query_mock.distinct.return_value = query_mock + # 最终 all() 给出结果 + query_mock.all.return_value = [("bag_z",), ("bag_a",)] + + res = srv.get_bag_names_linked_to_fst(db) + # 期望按 order_by 排好序 + assert res == [("bag_z",), ("bag_a",)] + + # 验证调用了 distinct 和 order_by + assert query_mock.distinct.call_count == 1 + query_mock.order_by.assert_called_once() + + def test_get_bag_names_linked_to_fst_empty(db): + """结果为空时返回空列表""" + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.order_by.return_value = query_mock + query_mock.distinct.return_value = query_mock + query_mock.all.return_value = [] + + res = srv.get_bag_names_linked_to_fst(db) + assert res == [] + + # ---------- print_fst_tree ---------- + def test_print_fst_tree_single_tree(db): + """单棵树:层级与字段正确""" + db.query.return_value.all.return_value = [ + ("1", "root", None), + ("2", "child", "1"), + ("3", "grand", "2"), + ] + trees = srv.print_fst_tree(db) + assert len(trees) == 1 + root = trees[0] + assert root["id"] == "root" + assert root["level"] == 0 + child = root["children"][0] + assert child["id"] == "child" + assert child["level"] == 1 + grand = child["children"][0] + assert grand["id"] == "grand" + assert grand["level"] == 2 + assert grand["children"] == [] + + def test_print_fst_tree_multi_trees(db): + """多棵树:返回顺序与根节点数量""" + db.query.return_value.all.return_value = [ + ("10", "treeA", None), + ("20", "treeB", None), + ("30", "subA", "10"), + ] + trees = srv.print_fst_tree(db) + assert len(trees) == 2 + assert [t["id"] for t in trees] == ["treeA", "treeB"] + assert trees[0]["children"][0]["id"] == "subA" + + def test_print_fst_tree_skip_dirty_data(db): + """脏数据(非数字 id/pid)自动跳过""" + db.query.return_value.all.return_value = [ + ("abc", "bad1", "1"), # 无效 id + ("1", "root", "xyz"), # 无效 pid,当根处理 + ("2", "child", "1"), # 正常 + ] + trees = srv.print_fst_tree(db) + # 仅保留有效节点,且 root 当成新根 + assert len(trees) == 1 + assert trees[0]["id"] == "root" + assert trees[0]["children"][0]["id"] == "child" + + +# ---------- batch_upsert_fst_bag ---------- +def test_batch_upsert_fst_bag_empty_items(db): + """空列表直接返回空""" + res = srv.batch_upsert_fst_bag(db, []) + assert res == [] + db.query.assert_not_called() + + +def test_batch_upsert_fst_bag_normal(db, monkeypatch): + """正常流程:预取、建标签、去重、逐条 upsert""" + # 预取阶段 + node_name2id = {"root": 1, "child": 2} + tag_name2id = {"hot": 11} + bag_name2id = {"bag1": 101} + monkeypatch.setattr( + srv, "_prefetch", lambda _db, _items: (node_name2id, tag_name2id, bag_name2id) + ) + monkeypatch.setattr(srv, "_ensure_tags", lambda _db, _names, _m: None) + + # 已存在关系 + db.query.return_value.filter.return_value.all.return_value = [] + + # 逐条 upsert mock + fake_result = MagicMock() + monkeypatch.setattr(srv, "_upsert_one_item", lambda *_args: fake_result) + + items = [MagicMock(bag_name="bag1", nodes=["root", "child"], tags=["hot"])] + res = srv.batch_upsert_fst_bag(db, items) + assert res == [fake_result] + + +# ---------- delete_fst_by_fst_name ---------- +def test_delete_fst_by_fst_name_not_found(db): + """节点不存在抛 ValueError""" + db.query.return_value.filter.return_value.first.return_value = None + with pytest.raises(ValueError, match="FST node 'no' not found"): + srv.delete_fst_by_fst_name(db, "no") + + +def test_delete_fst_by_fst_name_ok(db): + """存在节点:子节点变根,再删除""" + node = MagicMock(id=99) + db.query.return_value.filter.return_value.first.return_value = node + db.execute = MagicMock() + db.delete = MagicMock() + + srv.delete_fst_by_fst_name(db, "to_del") + + # 子节点 parent_id 被置空 + db.execute.assert_called_once() + call = db.execute.call_args[0][0] + assert "parent_id" in str(call) + db.delete.assert_called_once_with(node) + + +# ---------- upsert_fst_node ---------- +def test_upsert_fst_node_insert_root(db): + """新建根节点""" + db.query.return_value.filter.return_value.first.return_value = None + new_node = MagicMock() + db.add = MagicMock() + db.flush = MagicMock() + + with patch( + "fst_data_pipeline.apps.root_db_api.src.core.service.FST", return_value=new_node + ): + node = srv.upsert_fst_node(db, name="root") + assert node is new_node + db.add.assert_called_once_with(new_node) + db.flush.assert_called_once() + + +def test_upsert_fst_node_update_with_parent(db): + """更新已有节点,同时换父节点""" + old_parent = MagicMock(id=10) + new_parent = MagicMock(id=20) + node = MagicMock(parent_id=10) + db.query.return_value.filter.return_value.first.side_effect = [new_parent, node] + db.flush = MagicMock() + + res = srv.upsert_fst_node(db, name="n1", parent_name="new_p", bag_sum=5) + assert res.parent_id == 20 + assert res.bag_sum == 5 + db.flush.assert_called_once() + + +def test_upsert_fst_node_parent_not_found(db): + """指定父节点不存在抛 ValueError""" + db.query.return_value.filter.return_value.first.return_value = None + with pytest.raises(ValueError, match="Parent FST node 'no' not found"): + srv.upsert_fst_node(db, name="n1", parent_name="no") + + +# ---------- Recompute ---------- +def test_get_bag_recompute_versions(db): + """测试获取bag的recompute版本列表""" + # Mock join查询结果 - 返回bag_name和recompute_version对 + result1 = MagicMock() + result1.bag_name = "bag1.bag" + result1.recompute_version = "v1.0" + + result2 = MagicMock() + result2.bag_name = "bag1.bag" + result2.recompute_version = "v1.1" + + result3 = MagicMock() + result3.bag_name = "bag2.bag" + result3.recompute_version = "v1.0" + + result4 = MagicMock() + result4.bag_name = "bag2.bag" + result4.recompute_version = "v2.0" + + # Mock query chain + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.all.return_value = [result1, result2, result3, result4] + + result = srv.get_bag_recompute_versions(db, ["bag1.bag", "bag2.bag"]) + + expected = {"bag1.bag": ["v1.0", "v1.1"], "bag2.bag": ["v1.0", "v2.0"]} + assert result == expected + + +def test_get_bag_recompute_versions_empty_bags(db): + """测试空bag列表的处理""" + # Mock query chain - 空bag列表仍会执行查询,但filter为空 + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.all.return_value = [] + + result = srv.get_bag_recompute_versions(db, []) + assert result == {} + + +def test_get_bag_recompute_versions_bag_not_found(db): + """测试bag不存在的情况""" + # Mock 空的查询结果 + db.query.return_value.filter.return_value.all.return_value = [] + + result = srv.get_bag_recompute_versions(db, ["nonexistent.bag"]) + assert result == {"nonexistent.bag": []} + + +def test_get_storage_paths_batch(db): + """测试批量获取存储路径""" + from datetime import datetime + + # Mock join查询结果 - 使用实际查询中select的字段名 + result1 = MagicMock() + result1.result_id = 1 + result1.bag_name = "bag1.bag" + result1.recompute_version = "v1.0" + result1.result_type = "perception" + result1.storage_path = "/path/to/result1" + result1.status = "available" + result1.created_time = datetime.fromisoformat("2024-01-16T10:30:00") + result1.reserved_json = {} + + # Mock query chain + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.order_by.return_value = query_mock + query_mock.all.return_value = [result1] + + requests = [{"bag_name": "bag1.bag", "recompute_version": "v1.0"}] + results = srv.get_storage_paths_batch(db, requests) + + expected = [ + { + "bag_name": "bag1.bag", + "recompute_version": "v1.0", + "results": [ + { + "result_id": 1, + "bag_name": "bag1.bag", + "recompute_version": "v1.0", + "result_type": "perception", + "storage_path": "/path/to/result1", + "status": "available", + "created_time": "2024-01-16T10:30:00", + "reserved_json": {}, + } + ], + } + ] + assert results == expected + + +def test_get_storage_paths_batch_empty_requests(db): + """测试空请求列表""" + result = srv.get_storage_paths_batch(db, []) + assert result == [] + # 确保没有数据库查询 + db.query.assert_not_called() + + +def test_get_storage_paths_batch_no_results(db): + """测试没有找到结果的情况""" + # Mock 空的查询结果 + query_mock = db.query.return_value + query_mock.join.return_value = query_mock + query_mock.filter.return_value = query_mock + query_mock.order_by.return_value = query_mock + query_mock.all.return_value = [] + + requests = [{"bag_name": "nonexistent.bag", "recompute_version": "v1.0"}] + results = srv.get_storage_paths_batch(db, requests) + + expected = [ + {"bag_name": "nonexistent.bag", "recompute_version": "v1.0", "results": []} + ] + assert results == expected + + +def test_query_all_joined_bags(db): + """测试查询所有合并bags的parent-children关系""" + # 直接Mock三个查询的返回值,避免复杂的side_effect逻辑 + + # Mock第一个查询:获取所有parent_id + first_query = MagicMock() + first_query.distinct.return_value.scalar_subquery.return_value = [1, 2] + + # Mock第二个查询:获取parent_id到children_name的映射 + second_query = MagicMock() + second_query.join.return_value = second_query + second_query.filter.return_value = second_query + second_query.order_by.return_value = second_query + second_query.all.return_value = [ + (1, "child1.bag"), + (1, "child2.bag"), + (2, "child3.bag"), + ] + + # Mock第三个查询:获取parent_id到parent_name的映射 + third_query = MagicMock() + third_query.filter.return_value = third_query + third_query.all.return_value = [ + (1, "parent1.bag"), + (2, "parent2.bag"), + ] + + # 使用side_effect返回不同的mock对象 + db.query.side_effect = [first_query, second_query, third_query] + + result = srv.query_all_joined_bags(db) + + # 验证结果结构 + assert isinstance(result, dict) + assert "parent1.bag" in result + assert "parent2.bag" in result + assert result["parent1.bag"] == ["child1.bag", "child2.bag"] + assert result["parent2.bag"] == ["child3.bag"] + + +def test_query_all_joined_bags_empty(db): + """测试查询所有合并bags - 空结果""" + from fst_data_pipeline.apps.root_db_api.src.core.models import JoinedBags + + # Mock空结果 + db.query.return_value.distinct.return_value.scalar_subquery.return_value = ( + MagicMock() + ) + + joined_query_mock = MagicMock() + joined_query_mock.join.return_value = joined_query_mock + joined_query_mock.filter.return_value = joined_query_mock + joined_query_mock.order_by.return_value = joined_query_mock + joined_query_mock.all.return_value = [] + + def query_side_effect(*args, **kwargs): + if len(args) == 1 and args[0] == JoinedBags.parent_id: + return db.query.return_value + else: + return joined_query_mock + + db.query.side_effect = query_side_effect + + result = srv.query_all_joined_bags(db) + + # 验证空结果 + assert result == {} diff --git a/fst_data_pipeline/apps/root_db_api/uv.lock b/fst_data_pipeline/apps/root_db_api/uv.lock new file mode 100644 index 0000000..71aaa3f --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/uv.lock @@ -0,0 +1,1154 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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 = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "branca" +version = "0.8.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "jinja2" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/14/9d409124bda3f4ab7af3802aba07181d1fd56aa96cc4b999faea6a27a0d2/branca-0.8.2.tar.gz", hash = "sha256:e5040f4c286e973658c27de9225c1a5a7356dd0702a7c8d84c0f0dfbde388fe7", size = 27890, upload-time = "2025-10-06T10:28:20.305Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7e/50/fc9680058e63161f2f63165b84c957a0df1415431104c408e8104a3a18ef/branca-0.8.2-py3-none-any.whl", hash = "sha256:2ebaef3983e3312733c1ae2b793b0a8ba3e1c4edeb7598e10328505280cf2f7c", size = 26193, upload-time = "2025-10-06T10:28:19.255Z" }, +] + +[[package]] +name = "cachelib" +version = "0.13.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48", size = 34418, upload-time = "2024-04-13T14:18:27.782Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", size = 20914, upload-time = "2024-04-13T14:18:26.361Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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 = "cos-python-sdk-v5" +version = "1.9.37" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "crcmod" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "six" }, + { name = "xmltodict" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/be/ab/e8ee64748f3b9ecf835041c26c519d276ba868c4726daad7bbfdb6a12bcd/cos_python_sdk_v5-1.9.37.tar.gz", hash = "sha256:59b34b39e55e3afbe9f94c396b57a13c7055c7e1013e5de873bd985cf7ca3858", size = 96356, upload-time = "2025-05-16T11:10:06.882Z" } + +[[package]] +name = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, +] + +[[package]] +name = "crcmod" +version = "1.7" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "flasgger" +version = "0.9.7b2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "flask" }, + { name = "jsonschema" }, + { name = "mistune" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "six" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/cf/f4/7b17871ee710c16644b20a379e31998eddbd2a455251d4bd8da05e8e47ae/flasgger-0.9.7b2.tar.gz", hash = "sha256:5c4328b0ad7b5c3768f36a1f7eda5fb28c4495d2f1b3d90621670643fb57a549", size = 3979241, upload-time = "2023-05-11T17:52:06.302Z" } + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + +[[package]] +name = "flask-caching" +version = "2.3.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "cachelib" }, + { name = "flask" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/e2/80/74846c8af58ed60972d64f23a6cd0c3ac0175677d7555dff9f51bf82c294/flask_caching-2.3.1.tar.gz", hash = "sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9", size = 67560, upload-time = "2025-02-23T01:34:40.207Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/00/bb/82daa5e2fcecafadcc8659ce5779679d0641666f9252a4d5a2ae987b0506/Flask_Caching-2.3.1-py3-none-any.whl", hash = "sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761", size = 28916, upload-time = "2025-02-23T01:34:37.749Z" }, +] + +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" }, +] + +[[package]] +name = "folium" +version = "0.20.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "branca" }, + { name = "jinja2" }, + { name = "numpy" }, + { name = "requests" }, + { name = "xyzservices" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/c7/76/84a1b1b00ce71f9c0c44af7d80f310c02e2e583591fe7d4cb03baecd0d3f/folium-0.20.0.tar.gz", hash = "sha256:a0d78b9d5a36ba7589ca9aedbd433e84e9fcab79cd6ac213adbcff922e454cb9", size = 109932, upload-time = "2025-06-16T20:22:51.803Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b5/a8/5f764f333204db0390362a4356d03a43626997f26818a0e9396f1b3bd8c9/folium-0.20.0-py2.py3-none-any.whl", hash = "sha256:f0bc2a92acde20bca56367aa5c1c376c433f450608d058daebab2fc9bf8198bf", size = 113394, upload-time = "2025-06-16T20:22:50.318Z" }, +] + +[[package]] +name = "geoalchemy2" +version = "0.17.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/46/52/60214c086a57a7e3ea82241bab94e09827f2b7f7c2084fe6fc280c099b23/geoalchemy2-0.17.1.tar.gz", hash = "sha256:ff5bbe0db5a4ff979f321c8aa1a7556f444ea30cda5146189b1a177ae5bec69d", size = 231566, upload-time = "2025-02-17T09:41:04.72Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f5/82/e83e0f74cba8bbff9c12b2dea4693adb0cf89204012da48433ebd8e8c4d8/GeoAlchemy2-0.17.1-py3-none-any.whl", hash = "sha256:29f41b67d3a52df47821b695d31dec8600747c6ef4de62ee69811bde481dd2ae", size = 77791, upload-time = "2025-02-17T09:41:02.7Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mistune" +version = "3.1.4" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b", size = 20390372, upload-time = "2025-06-21T12:28:33.469Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c6/56/71ad5022e2f63cfe0ca93559403d0edef14aea70a841d640bd13cdba578e/numpy-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2959d8f268f3d8ee402b04a9ec4bb7604555aeacf78b360dc4ec27f1d508177d", size = 20896664, upload-time = "2025-06-21T12:15:30.845Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/25/65/2db52ba049813670f7f987cc5db6dac9be7cd95e923cc6832b3d32d87cef/numpy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:762e0c0c6b56bdedfef9a8e1d4538556438288c4276901ea008ae44091954e29", size = 14131078, upload-time = "2025-06-21T12:15:52.23Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/57/dd/28fa3c17b0e751047ac928c1e1b6990238faad76e9b147e585b573d9d1bd/numpy-2.3.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:867ef172a0976aaa1f1d1b63cf2090de8b636a7674607d514505fb7276ab08fc", size = 5112554, upload-time = "2025-06-21T12:16:01.434Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/fc/84ea0cba8e760c4644b708b6819d91784c290288c27aca916115e3311d17/numpy-2.3.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4e602e1b8682c2b833af89ba641ad4176053aaa50f5cacda1a27004352dde943", size = 6646560, upload-time = "2025-06-21T12:16:11.895Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/61/b2/512b0c2ddec985ad1e496b0bd853eeb572315c0f07cd6997473ced8f15e2/numpy-2.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8e333040d069eba1652fb08962ec5b76af7f2c7bce1df7e1418c8055cf776f25", size = 14260638, upload-time = "2025-06-21T12:16:32.611Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6e/45/c51cb248e679a6c6ab14b7a8e3ead3f4a3fe7425fc7a6f98b3f147bec532/numpy-2.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e7cbf5a5eafd8d230a3ce356d892512185230e4781a361229bd902ff403bc660", size = 16632729, upload-time = "2025-06-21T12:16:57.439Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/ff/feb4be2e5c09a3da161b412019caf47183099cbea1132fd98061808c2df2/numpy-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1b8f26d1086835f442286c1d9b64bb3974b0b1e41bb105358fd07d20872952", size = 15565330, upload-time = "2025-06-21T12:17:20.638Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/6d/ceafe87587101e9ab0d370e4f6e5f3f3a85b9a697f2318738e5e7e176ce3/numpy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee8340cb48c9b7a5899d1149eece41ca535513a9698098edbade2a8e7a84da77", size = 18361734, upload-time = "2025-06-21T12:17:47.938Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2b/19/0fb49a3ea088be691f040c9bf1817e4669a339d6e98579f91859b902c636/numpy-2.3.1-cp312-cp312-win32.whl", hash = "sha256:e772dda20a6002ef7061713dc1e2585bc1b534e7909b2030b5a46dae8ff077ab", size = 6320411, upload-time = "2025-06-21T12:17:58.475Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b1/3e/e28f4c1dd9e042eb57a3eb652f200225e311b608632bc727ae378623d4f8/numpy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfecc7822543abdea6de08758091da655ea2210b8ffa1faf116b940693d3df76", size = 12734973, upload-time = "2025-06-21T12:18:17.601Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/a8/8a5e9079dc722acf53522b8f8842e79541ea81835e9b5483388701421073/numpy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:7be91b2239af2658653c5bb6f1b8bccafaf08226a258caf78ce44710a0160d30", size = 10191491, upload-time = "2025-06-21T12:18:33.585Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8", size = 20889381, upload-time = "2025-06-21T12:19:04.103Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e", size = 14152726, upload-time = "2025-06-21T12:19:25.599Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0", size = 5105145, upload-time = "2025-06-21T12:19:34.782Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d", size = 6639409, upload-time = "2025-06-21T12:19:45.228Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1", size = 14257630, upload-time = "2025-06-21T12:20:06.544Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1", size = 16627546, upload-time = "2025-06-21T12:20:31.002Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0", size = 15562538, upload-time = "2025-06-21T12:20:54.322Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8", size = 18360327, upload-time = "2025-06-21T12:21:21.053Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8", size = 6312330, upload-time = "2025-06-21T12:25:07.447Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42", size = 12731565, upload-time = "2025-06-21T12:25:26.444Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e", size = 10190262, upload-time = "2025-06-21T12:25:42.196Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8", size = 20987593, upload-time = "2025-06-21T12:21:51.664Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb", size = 14300523, upload-time = "2025-06-21T12:22:13.583Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee", size = 5227993, upload-time = "2025-06-21T12:22:22.53Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992", size = 6736652, upload-time = "2025-06-21T12:22:33.629Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c", size = 14331561, upload-time = "2025-06-21T12:22:55.056Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48", size = 16693349, upload-time = "2025-06-21T12:23:20.53Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee", size = 15642053, upload-time = "2025-06-21T12:23:43.697Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280", size = 18434184, upload-time = "2025-06-21T12:24:10.708Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e", size = 6440678, upload-time = "2025-06-21T12:24:21.596Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc", size = 12870697, upload-time = "2025-06-21T12:24:40.644Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244", size = 10260376, upload-time = "2025-06-21T12:24:56.884Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.22.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/5e/cf/40dde0a2be27cc1eb41e333d1a674a74ce8b8b0457269cc640fd42b07cf7/prometheus_client-0.22.1.tar.gz", hash = "sha256:190f1331e783cf21eb60bca559354e0a4d4378facecf78f5428c39b675d20d28", size = 69746, upload-time = "2025-06-02T14:29:01.152Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/ae/ec06af4fe3ee72d16973474f122541746196aaa16cea6f66d18b963c6177/prometheus_client-0.22.1-py3-none-any.whl", hash = "sha256:cca895342e308174341b2cbf99a56bef291fbc0ef7b9e5412a0f26d653ba7094", size = 58694, upload-time = "2025-06-02T14:29:00.068Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/88/17/139b134cb36e496a62780b2ff19ea47fd834f2d180a32e6dd9210f4a8a77/pytest_cov-6.2.0.tar.gz", hash = "sha256:9a4331e087a0f5074dc1e19fe0485a07a462b346cbb91e2ac903ec5504abce10", size = 68872, upload-time = "2025-06-11T21:55:02.68Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/66/a38138fbf711b2b93592dfd7303bba561f6bc05f85361a0388c105ceb727/pytest_cov-6.2.0-py3-none-any.whl", hash = "sha256:bd19301caf600ead1169db089ed0ad7b8f2b962214330a696b8c85a0b497b2ff", size = 24448, upload-time = "2025-06-11T21:55:00.938Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814, upload-time = "2024-03-21T22:14:04.964Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863, upload-time = "2024-03-21T22:14:02.694Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "root-db-api" +version = "0.5.0" +source = { editable = "." } +dependencies = [ + { name = "cos-python-sdk-v5" }, + { name = "flasgger" }, + { name = "flask" }, + { name = "flask-caching" }, + { name = "flask-sqlalchemy" }, + { name = "folium" }, + { name = "geoalchemy2" }, + { name = "gunicorn" }, + { name = "numpy" }, + { name = "openpyxl" }, + { name = "prometheus-client" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "python-dotenv" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "shapely" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, + { name = "tqdm" }, +] + +[package.metadata] +requires-dist = [ + { name = "cos-python-sdk-v5", specifier = "==1.9.37" }, + { name = "flasgger", specifier = "==0.9.7b2" }, + { name = "flask", specifier = ">=3.1.1" }, + { name = "flask-caching", specifier = ">=2.3.1" }, + { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, + { name = "folium", specifier = "==0.20.0" }, + { name = "geoalchemy2", specifier = "==0.17.1" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "numpy", specifier = "==2.3.1" }, + { name = "openpyxl", specifier = ">=3.1.0" }, + { name = "prometheus-client", specifier = "==0.22.1" }, + { name = "psycopg2-binary", specifier = "==2.9.10" }, + { name = "pydantic", specifier = "==2.11.7" }, + { name = "pytest", specifier = "==8.4.1" }, + { name = "pytest-cov", specifier = "==6.2.0" }, + { name = "pytest-mock", specifier = "==3.14.0" }, + { name = "python-dotenv", specifier = "==1.1.1" }, + { name = "pytz", specifier = "==2025.2" }, + { name = "pyyaml", specifier = "==6.0.2" }, + { name = "redis", specifier = ">=6.4.0" }, + { name = "requests", specifier = "==2.32.4" }, + { name = "shapely", specifier = ">=2.1.1" }, + { name = "sqlalchemy", specifier = "==2.0.41" }, + { name = "tenacity", specifier = "==9.1.2" }, + { name = "tqdm", specifier = "==4.67.1" }, +] + +[[package]] +name = "rpds-py" +version = "0.29.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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 = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload-time = "2025-05-14T17:55:24.854Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload-time = "2025-05-14T17:55:28.097Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload-time = "2025-05-14T17:50:38.227Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload-time = "2025-05-14T17:51:49.829Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload-time = "2025-05-14T17:50:39.774Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload-time = "2025-05-14T17:55:31.177Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload-time = "2025-05-14T17:55:34.921Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload-time = "2025-05-14T17:50:41.418Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload-time = "2025-05-14T17:51:54.722Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload-time = "2025-05-14T17:50:43.483Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload-time = "2025-05-14T17:51:57.308Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload-time = "2025-05-14T17:55:52.69Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload-time = "2025-05-14T17:55:54.495Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/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://mirrors.ustc.edu.cn/pypi/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 = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] + +[[package]] +name = "xmltodict" +version = "1.0.2" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649", size = 25725, upload-time = "2025-09-17T21:59:26.459Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" }, +] + +[[package]] +name = "xyzservices" +version = "2025.11.0" +source = { registry = "https://pypi.mirrors.ustc.edu.cn/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" }, +] diff --git a/fst_data_pipeline/core/README.md b/fst_data_pipeline/core/README.md new file mode 100644 index 0000000..7dc779b --- /dev/null +++ b/fst_data_pipeline/core/README.md @@ -0,0 +1,3 @@ +# Core module + +Shared lib and utils in fst_data_pipeline project \ No newline at end of file diff --git a/fst_data_pipeline/core/__init__.py b/fst_data_pipeline/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/core/config_manager.py b/fst_data_pipeline/core/config_manager.py new file mode 100644 index 0000000..642729a --- /dev/null +++ b/fst_data_pipeline/core/config_manager.py @@ -0,0 +1,100 @@ +import os +from dotenv import load_dotenv, find_dotenv + + +class ConfigManager: + """ + 可重用的配置管理工具类,支持多环境配置和敏感信息管理 + 使用示例: + config = ConfigManager(env="production") + db_url = config.get("DATABASE_URL") + debug_mode = config.get_bool("DEBUG_MODE", default=False) + """ + + def __init__(self, env=None, env_file_path=None): + """ + 初始化配置管理器 + :param env: 环境名称(如'development', 'production') + :param env_file_path: 自定义.env文件路径 + """ + self._loaded = False + self.env = env or os.getenv("APP_ENV", "development") + self._load_config(env_file_path) + + def _load_config(self, custom_path=None): + """加载环境变量配置""" + if self._loaded: + return + # 确定要加载的.env文件 + env_file = custom_path or f".env.{self.env}" + + # 尝试加载指定环境的文件,不存在则加载默认.env + if not os.path.exists(env_file) and not custom_path: + env_file = "../.env" + + # 使用dotenv加载配置 + if os.path.exists(env_file): + load_dotenv(env_file, override=True) + else: + # 尝试自动发现.env文件 + found_dotenv = find_dotenv(usecwd=True) + if found_dotenv: + load_dotenv(found_dotenv, override=True) + + self._loaded = True + + def get(self, key, default=None): + """ + 获取字符串配置值 + :param key: 配置键名 + :param default: 默认值(未找到时返回) + """ + return os.getenv(key, default) + + def get_int(self, key, default=None): + """ + 获取整数配置值 + :param key: 配置键名 + :param default: 默认值(未找到或转换失败时返回) + """ + value = self.get(key) + try: + return int(value) if value is not None else default + except (TypeError, ValueError): + return default + + def get_bool(self, key, default=False): + """ + 获取布尔值配置 + :param key: 配置键名 + :param default: 默认值(未找到或转换失败时返回) + """ + value = self.get(key, "").lower() + if value in ["true", "1", "yes", "y"]: + return True + elif value in ["false", "0", "no", "n"]: + return False + return default + + def get_float(self, key, default=None): + """ + 获取浮点数配置值 + :param key: 配置键名 + :param default: 默认值(未找到或转换失败时返回) + """ + value = self.get(key) + try: + return float(value) if value is not None else default + except (TypeError, ValueError): + return default + + def require(self, key): + """ + 获取必须存在的配置值,不存在则抛出异常 + :param key: 配置键名 + :raises EnvironmentError: 当配置不存在时 + """ + value = self.get(key) + if not value: + raise EnvironmentError(f"必需配置项缺失: {key}") + return value diff --git a/fst_data_pipeline/pipelines/README.md b/fst_data_pipeline/pipelines/README.md new file mode 100644 index 0000000..a91d6ef --- /dev/null +++ b/fst_data_pipeline/pipelines/README.md @@ -0,0 +1,2 @@ +# fst data production automation pipeline +all automation scripts to enable fst data production line. \ No newline at end of file diff --git a/fst_data_pipeline/pipelines/__init__.py b/fst_data_pipeline/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/pipelines/tencent/4dod_prod.py b/fst_data_pipeline/pipelines/tencent/4dod_prod.py new file mode 100644 index 0000000..61d987f --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/4dod_prod.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +import os +import sys +import time +import argparse +import threading +import queue +import subprocess +import shutil +import logging +from datetime import datetime + +from prometheus_client import start_http_server, Counter, Gauge, Summary, Histogram + +# —— 常量 & 路径 —— # +BASE = os.getcwd() +INPUT_ROOT = os.path.join(BASE, "input") +OUTPUT_ROOT = os.path.join(BASE, "output") +EMPTY_DIR = os.path.join(BASE, "empty") +LOG_DIR = os.path.join(BASE, "logs") + +EXCLUDE_SUBDIRS = ["struct_infos/*"] +DOCKER_IMAGE = ( + "artifact.swfcn.i.mercedes-benz.com/swfcn_docker/perception-dnn/od-net:prod_v0.2" +) + +BATCH_SIZE = 10 +MAX_LOCAL = 20 +MAX_RETRIES = 3 +RETRY_DELAY_S = 2 +METRICS_PORT = 8001 +SENTINEL = (None, None) + +# —— 日志配置 —— # +os.makedirs(LOG_DIR, exist_ok=True) +logger = logging.getLogger("pipeline") +logger.setLevel(logging.INFO) +h_info = logging.FileHandler(os.path.join(LOG_DIR, "pipeline.log"), encoding="utf-8") +h_err = logging.FileHandler(os.path.join(LOG_DIR, "error_tasks.log"), encoding="utf-8") +fmt = logging.Formatter("%(asctime)s %(levelname)s [%(threadName)s] %(message)s") +h_info.setFormatter(fmt) +h_err.setFormatter(fmt) +h_err.setLevel(logging.ERROR) +logger.addHandler(h_info) +logger.addHandler(h_err) + +# —— Prometheus 指标 —— # +DL_TOTAL = Counter("pipeline_download_total", "下载尝试总数") +DL_FAIL = Counter("pipeline_download_failures", "下载失败总数") +DL_RETRY = Counter("pipeline_download_retries", "下载重试总数") +PR_TOTAL = Counter("pipeline_process_total", "处理尝试总数") +PR_FAIL = Counter("pipeline_process_failures", "处理失败总数") +PR_RETRY = Counter("pipeline_process_retries", "处理重试总数") +UP_TOTAL = Counter("pipeline_upload_total", "上传尝试总数") +UP_FAIL = Counter("pipeline_upload_failures", "上传失败总数") +UP_RETRY = Counter("pipeline_upload_retries", "上传重试总数") + +DL_DUR = Summary("pipeline_download_duration_seconds", "单批下载耗时秒") +PR_DUR = Summary("pipeline_process_duration_seconds", "单批处理耗时秒") +UP_DUR = Summary("pipeline_upload_duration_seconds", "单批上传耗时秒") + +BATCH_SIZE_HIST = Histogram( + "pipeline_batch_size", + "单批任务中文件夹数量分布", + buckets=[1, 10, 20, 50, 100, 200, 500], +) +FILE_DL_DUR = Histogram( + "pipeline_file_download_duration_seconds", "单文件/单目录下载耗时分布" +) +BATCH_OUT_FILES = Gauge( + "pipeline_batch_output_subfolder_count", "单批处理后 output 下指定子文件夹数" +) + +Q_BATCH = Gauge("pipeline_queue_batches", "待下载批次数") +Q_PROC = Gauge("pipeline_queue_processing", "待处理批次数") +Q_UP = Gauge("pipeline_queue_uploading", "待上传批次数") +LOCAL_COUNT = Gauge("pipeline_local_subdir_count", "当前本地 input 子文件夹总数") + +# —— 队列 & 控制 —— # +batch_q = queue.Queue() +proc_q = queue.Queue() +up_q = queue.Queue() + +# —— 全局计数 & 锁,用于减少磁盘扫描 —— # +_local_counter = 0 +_counter_lock = threading.Lock() +_downloaded_per_batch = {} # batch_id -> 成功下载的子目录数量 + + +def incr_local(n=1, batch_id=None): + global _local_counter + with _counter_lock: + _local_counter += n + if batch_id: + _downloaded_per_batch.setdefault(batch_id, 0) + _downloaded_per_batch[batch_id] += n + + +def decr_local_batch(batch_id): + global _local_counter + with _counter_lock: + n = _downloaded_per_batch.pop(batch_id, 0) + _local_counter -= n + if _local_counter < 0: + _local_counter = 0 + + +def get_local_count(): + with _counter_lock: + return _local_counter + + +# —— 子目录限流 —— # +def count_local_subdirs_in_batch(batch_dir): + # 只统计当前 batch 下直接子目录 + if not os.path.isdir(batch_dir): + return 0 + return sum( + 1 for e in os.listdir(batch_dir) if os.path.isdir(os.path.join(batch_dir, e)) + ) + + +# —— 统一 subprocess + 重试 —— # +def run(cmd, timeout=None): + """ + 统一调用 subprocess,记录日志,并返回 (code, timed_out, output). + """ + logger.info("RUN: %s", " ".join(cmd)) + try: + p = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + start = time.time() + output = [] + timed_out = False + for line in p.stdout: + output.append(line) + if timeout and (time.time() - start) > timeout: + p.kill() + timed_out = True + break + code = p.wait() + out_str = "".join(output) + if code != 0: + logger.warning("CMD 返回非零(%d): %s", code, out_str.strip()) + return code, timed_out, out_str + except Exception as e: + logger.exception("RUN 异常: %s", e) + return -1, False, "" + + +def with_retry(tag, func, *args): + """ + 重试包装,func 必须返回 (code, timed_out, output) + """ + for i in range(1, MAX_RETRIES + 1): + code, timed_out, out = func(*args) + if code == 0: + return True + if timed_out: + logger.error("%s 超时,不再重试", tag) + break + # 统计重试 + if tag.startswith("DL["): + DL_RETRY.inc() + if tag.startswith("PR["): + PR_RETRY.inc() + if tag.startswith("UP["): + UP_RETRY.inc() + logger.warning("%s 重试 %d/%d, output: %s", tag, i, MAX_RETRIES, out.strip()) + time.sleep(RETRY_DELAY_S) + logger.error("%s 最终失败", tag) + return False + + +# —— 删除软连接 —— # +def delete_symlinks(root_path): + """ + 调用 find 一次性删除所有软链接,效率更高。 + """ + logger.info("删除软连接: %s", root_path) + code, _, out = run( + ["sudo", "find", root_path, "-type", "l", "-delete"], timeout=120 + ) + if code != 0: + logger.warning("删除软连接失败: %s", out.strip()) + + +# —— 下载阶段 —— # +@DL_DUR.time() +def do_download(batch_id, remote_paths, batch_timeout): + if batch_id is None: + proc_q.put(SENTINEL) + return + + DL_TOTAL.inc() + start = time.time() + in_dir = os.path.join(INPUT_ROOT, batch_id) + os.makedirs(in_dir, exist_ok=True) + logger.info("DL[%s] 开始, targets=%s", batch_id, remote_paths) + + # 本地同 batch 子目录限流 + while _local_counter >= MAX_LOCAL: + logger.warning("DL[%s] 本地子目录≥%d,sleep 5min", batch_id, MAX_LOCAL) + time.sleep(300) + + exclude_flags = [] + for sub in EXCLUDE_SUBDIRS: + exclude_flags += ["--ignore", f"'{sub}'"] + + success_count = 0 + for remote in remote_paths: + # 检查 lidar_gt_pandar128 + lidar_sub = remote.rstrip("/") + "/lidar_gt_pandar128" + code, _, out = run(["coscmd", "list", lidar_sub]) + if code != 0 or not out.strip(): + logger.info("DL[%s] 跳过无数据: %s", batch_id, lidar_sub) + continue + + elapsed = time.time() - start + if elapsed > batch_timeout: + logger.error("DL[%s] 超时,停止下载", batch_id) + DL_FAIL.inc() + break + + f_start = time.time() + local_bag = os.path.join(in_dir, os.path.basename(remote)) + cmd = ["coscmd", "-s", "download", "-r", remote, local_bag] + exclude_flags + ok = with_retry( + f"DL[{batch_id}]", lambda c: run(c, timeout=batch_timeout - elapsed), cmd + ) + FILE_DL_DUR.observe(time.time() - f_start) + if ok: + success_count += 1 + incr_local(1, batch_id) # 成功下载一个子目录 + else: + DL_FAIL.inc() + + logger.info("DL[%s] 完成,成功 %d", batch_id, success_count) + proc_q.put((batch_id, (in_dir, remote_paths))) + + +# —— 处理阶段 —— # +@PR_DUR.time() +def do_process(batch_id, data, batch_timeout): + if batch_id is None: + up_q.put(SENTINEL) + return + + in_dir, remote_paths = data + PR_TOTAL.inc() + start = time.time() + out_dir = os.path.join(OUTPUT_ROOT, batch_id) + os.makedirs(out_dir, exist_ok=True) + logger.info("PR[%s] 开始", batch_id) + + # 统一用 run() 运行 Docker + docker_cmd = [ + "docker", + "run", + "--rm", + "--gpus", + "all", + "--shm-size=16g", + "-v", + f"{in_dir}:/input", + "-v", + f"{out_dir}:/output", + DOCKER_IMAGE, + "bash", + "-c", + "cd /code/projects/od_net/lidarnet/ && " + "source ../.venv/bin/activate && " + "python pipeline.py --input_dir=/input --output_dir=/output", + ] + ok = with_retry( + f"PR[{batch_id}]", lambda c: run(c, timeout=batch_timeout), docker_cmd + ) + elapsed = time.time() - start + if ok: + logger.info("PR[%s] 成功 耗时 %.1fs", batch_id, elapsed) + else: + PR_FAIL.inc() + logger.error("PR[%s] 失败 耗时 %.1fs", batch_id, elapsed) + + # 统计并清理输出子目录 + existing = [] + for name in os.listdir(out_dir): + path = os.path.join(out_dir, name) + if os.path.isdir(path) and name.endswith(".dir"): + existing.append(path) + BATCH_OUT_FILES.set(len(existing)) + logger.info("PR[%s] 输出子目录: %s", batch_id, existing) + + # for folder in existing: + # for item in os.listdir(folder): + # p = os.path.join(folder, item) + # if item != "object_tracking": + # # 非 object_tracking 全删 + # if os.path.isdir(p): + # shutil.rmtree(p, ignore_errors=True) + # else: + # os.remove(p) + # else: + # # 重命名 object_tracking + # newp = os.path.join(folder, "object_auto_labeling") + # os.rename(p, newp) + + shutil.rmtree(in_dir, ignore_errors=True) + up_q.put((batch_id, (out_dir, remote_paths))) + + +# —— 上传阶段 —— # +@UP_DUR.time() +def do_upload(batch_id, data, batch_timeout): + if batch_id is None: + return + + out_dir, remote_paths = data + UP_TOTAL.inc() + start = time.time() + logger.info("UP[%s] 开始", batch_id) + + # 删除中间产物 + for sub in ("logs", "intermedia_products"): + shutil.rmtree(os.path.join(out_dir, sub), ignore_errors=True) + + # 删除所有软连接 + delete_symlinks(out_dir) + + # 只上传 object_tracking -> remote/aaa + for remote in remote_paths: + base = remote.rstrip("/") + dest = f"{base}/derived/object_auto_labeling" + for root, dirs, _ in os.walk(out_dir): + if "object_tracking" in dirs: + src = os.path.join(root, "object_tracking") + elapsed = time.time() - start + if elapsed > batch_timeout: + logger.error("UP[%s] 超时", batch_id) + UP_FAIL.inc() + return + ok = with_retry( + f"UP[{batch_id}]", + lambda c: run(c, timeout=batch_timeout - elapsed), + ["coscmd", "-s", "upload", "-r", src, dest], + ) + if not ok: + UP_FAIL.inc() + logger.info("UP[%s] 上传完成", batch_id) + + # 清理本地输出 & 输入目录 + for cmd in [ + ["sudo", "rsync", "-a", "--delete", f"{EMPTY_DIR}/", f"{out_dir}/"], + ]: + code, _, _ = run(cmd, timeout=60) + if code != 0: + logger.warning("UP[%s] rsync 失败: %s", batch_id, cmd) + shutil.rmtree(out_dir, ignore_errors=True) + # 新增:删除 input 下对应 batch,释放磁盘 + in_dir = os.path.join(INPUT_ROOT, batch_id) + shutil.rmtree(in_dir, ignore_errors=True) + decr_local_batch(batch_id) + + logger.info("UP[%s] 本地清理完成", batch_id) + + +# —— Worker & 主流程 —— # +def worker(q, fn, timeout): + while True: + bid, data = q.get() + try: + fn(bid, data, timeout) + except Exception: + logger.exception("阶段异常,batch=%s", bid) + finally: + q.task_done() + if bid is None: + break + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--tasks-file", required=True, help="每行一个 COS 目录路径") + p.add_argument("--batch-size", type=int, default=BATCH_SIZE) + p.add_argument("--batch-timeout", type=int, default=3600) + args = p.parse_args() + T = args.batch_timeout + + # 确保基础目录 + for d in (INPUT_ROOT, OUTPUT_ROOT, EMPTY_DIR, LOG_DIR): + os.makedirs(d, exist_ok=True) + + # 读取并分批 + lines = [ + line.strip() for line in open(args.tasks_file, encoding="utf-8") if line.strip() + ] + for idx in range(0, len(lines), args.batch_size): + blk = lines[idx : idx + args.batch_size] + BATCH_SIZE_HIST.observe(len(blk)) + bid = ( + datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + + f"_{idx // args.batch_size + 1}" + ) + batch_q.put((bid, blk)) + batch_q.put(SENTINEL) + + # 启动监控 + start_http_server(METRICS_PORT) + logger.info("Metrics 服务启动,端口 %d", METRICS_PORT) + + # 启动线程 + threads = [ + threading.Thread( + target=worker, args=(batch_q, do_download, T), name="DL-Worker" + ), + threading.Thread(target=worker, args=(proc_q, do_process, T), name="PR-Worker"), + threading.Thread(target=worker, args=(up_q, do_upload, T), name="UP-Worker"), + ] + for t in threads: + t.start() + + # 主线程仅更新队列长度和本地目录计数,无全盘扫描 + while batch_q.unfinished_tasks or proc_q.unfinished_tasks or up_q.unfinished_tasks: + Q_BATCH.set(batch_q.qsize()) + Q_PROC.set(proc_q.qsize()) + Q_UP.set(up_q.qsize()) + LOCAL_COUNT.set(get_local_count()) + time.sleep(1) + + for t in threads: + t.join() + + logger.info("所有批次完成") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/__init__.py b/fst_data_pipeline/pipelines/tencent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/__init__.py b/fst_data_pipeline/pipelines/tencent/bag_operation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/auto_delete_bag_from_db.py b/fst_data_pipeline/pipelines/tencent/bag_operation/auto_delete_bag_from_db.py new file mode 100644 index 0000000..5710f0f --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/auto_delete_bag_from_db.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +# cleanup_bags_psql.py +""" +批量清理 bag_list 中 fst_indexed=FALSE 且日期早于 N 个月的记录: +1. 删除 COS 目录 +2. 删除关联 6 张表 +3. 标记 bag_list.is_deleted=TRUE +4. 失败自动重试 3 次,失败明细写 delete_failed.log +5. 最终生成 CSV 并打印统计 +""" + +import csv +import logging +import re +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta + +import psycopg2 +import requests +from qcloud_cos import CosConfig, CosS3Client +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, +) + +# 如果原来用 ConfigManager,保持不动;这里为了单文件可跑,直接用 os.environ 兜底 +import os + +# ===================== 配置 ===================== +DB_HOST = os.getenv("ROOT_DB_HOST", "localhost") +DB_PORT = int(os.getenv("ROOT_DB_PORT", 5432)) +DB_NAME = os.getenv("ROOT_DB_NAME", "default_dbname") +DB_USER = os.getenv("ROOT_DB_USER", "default_user") +DB_PASSWORD = os.getenv("ROOT_DB_PASSWD", "default_password") + +COS_REGION = os.getenv("COS_CLIENT_REGION", "ap-guangzhou") +COS_SECRET_ID = os.getenv("COS_CLIENT_SECRET_ID", "default_id") +COS_SECRET_KEY = os.getenv("COS_CLIENT_SECRET_KEY", "default_key") +COS_BUCKET = os.getenv("COS_BUCKET", "b-perception-e2e-1318950322") + +BASE_URL = os.getenv("ROOT_DB_API", "http://localhost") +PROJECT_IDS = (1, 2) +MONTHS = 6 +MAX_WORKERS = 10 +RETRY_TIMES = 3 + +CSV_FILE = f"deleted_bags_{datetime.now():%Y%m%d_%H%M%S}.csv" + +DB_CONF = dict( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, dbname=DB_NAME +) + +# ===================== 日志 ===================== +logging.basicConfig( + filename=f"cleanup_{datetime.now():%Y%m%d}.log", + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) + +# 失败明细日志 +failure_handler = logging.FileHandler("delete_failed.log", encoding="utf-8") +failure_handler.setFormatter(logging.Formatter("%(asctime)s,%(message)s")) +logger_failure = logging.getLogger("failure") +logger_failure.setLevel(logging.INFO) +logger_failure.addHandler(failure_handler) + +# ===================== COS 客户端 ===================== +cos = CosS3Client( + CosConfig(Region=COS_REGION, SecretId=COS_SECRET_ID, SecretKey=COS_SECRET_KEY) +) + +# ===================== 统计 ===================== +stats_lock = threading.Lock() +stats = {"total": 0, "success": 0, "fail": 0} + + +def inc_stat(key: str): + with stats_lock: + stats[key] += 1 + + +# ===================== 数据库工具 ===================== +def get_conn(): + return psycopg2.connect(**DB_CONF) + + +def fetch_candidates() -> list[tuple[int, str]]: + """返回 [(id, name), ...]""" + sql = """ + SELECT id, name + FROM bag_list + WHERE project_id = ANY(%s) + AND fst_indexed = FALSE + AND is_deleted = FALSE + """ + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, (PROJECT_IDS,)) + return cur.fetchall() + + +# ===================== 日期过滤 ===================== +DATE_RE = re.compile(r"_(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})_") + + +def need_delete(name: str) -> bool: + days = 30 * MONTHS + m = DATE_RE.search(name) + if not m: + return False + dt = datetime.strptime("".join(m.groups()), "%Y%m%d%H%M%S") + return dt < datetime.utcnow() - timedelta(days=days) + + +# ===================== 调接口拿 COS 路径 ===================== +def get_pangu_detail(bag_name: str): + url = f"{BASE_URL}/api/bags/pangu/detail" + resp = requests.get(url, params={"bagName": bag_name}, timeout=10) + resp.raise_for_status() + data = resp.json() + return data["dataPath"], data["rawPath"] + + +# ===================== COS 批量删前缀 ===================== +def cos_delete_prefix(prefix: str): + if not prefix: + return + marker = "" + while True: + resp = cos.list_objects(Bucket=COS_BUCKET, Prefix=prefix, Marker=marker) + contents = resp.get("Contents", []) + if not contents: + break + keys = [{"Key": obj["Key"]} for obj in contents] + cos.delete_objects(Bucket=COS_BUCKET, Delete={"Objects": keys}) + if resp.get("IsTruncated") == "false": + break + marker = resp["NextMarker"] + + +# ===================== 重试包装 ===================== +@retry( + stop=stop_after_attempt(RETRY_TIMES), + wait=wait_exponential(multiplier=2, min=4, max=30), + retry=retry_if_exception_type(( + requests.exceptions.RequestException, + psycopg2.OperationalError, + )), + reraise=True, +) +def _do_cleanup(bag_id: int, bag_name: str): + # 1. 拿路径并删 COS + data_path, raw_path = get_pangu_detail(bag_name) + cos_delete_prefix(data_path) + cos_delete_prefix(raw_path) + + # 2. 一个事务删关联表 + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + DELETE FROM bag_lifecycle WHERE bag_name = %s; + DELETE FROM secondary_pangu WHERE bag_id = %s; + DELETE FROM secondary_minerva WHERE bag_id = %s; + DELETE FROM bag_topic WHERE bag_id = %s; + DELETE FROM bag_reserved_tag WHERE bag_id = %s; + DELETE FROM fst_bag WHERE bag_id = %s; + UPDATE bag_list SET is_deleted = TRUE WHERE id = %s; + """, + (bag_name, bag_id, bag_id, bag_id, bag_id, bag_id, bag_id), + ) + conn.commit() + + +def process_one(bag_id: int, bag_name: str) -> tuple[bool, int, str]: + """外层统一入口:负责统计、重试、失败落盘""" + inc_stat("total") + try: + _do_cleanup(bag_id, bag_name) + inc_stat("success") + logging.info("Cleaned bag_id=%d name=%s", bag_id, bag_name) + return True, bag_id, bag_name + except Exception as e: + inc_stat("fail") + # 失败明细落盘 + logger_failure.info("%d,%s,%s", bag_id, bag_name, str(e).replace(",", ";")) + logging.error("Failed bag_id=%d name=%s error=%s", bag_id, bag_name, e) + return False, bag_id, bag_name + + +# ===================== 主流程 ===================== +def main(): + candidates = fetch_candidates() + logging.info("Fetched %d candidate bags", len(candidates)) + + to_delete = [(bid, nm) for bid, nm in candidates if need_delete(nm)] + logging.info("Need delete %d bags", len(to_delete)) + if not to_delete: + logging.info("Nothing to delete, exit.") + return + + results = [] + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: + futures = [pool.submit(process_one, bid, nm) for bid, nm in to_delete] + for f in as_completed(futures): + results.append(f.result()) + + # 写 CSV + with open(CSV_FILE, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["id", "name", "success"]) + for ok, bid, nm in results: + writer.writerow([bid, nm, ok]) + + # 最终汇总 + logging.info( + "=== Done: Total=%d Success=%d Fail=%d. CSV=%s FailureDetail=delete_failed.log ===", + stats["total"], + stats["success"], + stats["fail"], + CSV_FILE, + ) + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/bag_generate_video_update_db.py b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_generate_video_update_db.py new file mode 100644 index 0000000..59acfd1 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_generate_video_update_db.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import csv +import sys +import shutil +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +import subprocess +import argparse +from concurrent.futures import ProcessPoolExecutor, as_completed +from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception_type +import psycopg2 +from psycopg2 import OperationalError +from subprocess import CalledProcessError +from tqdm import tqdm +from fst_data_pipeline.core.config_manager import ConfigManager + +# ==== 1. 全局目录管理 ==== +ROOT_DIR = Path(__file__).parent.resolve() +LOG_DIR = ROOT_DIR / "logs" +DOWNLOAD_DIR = ROOT_DIR / "downloads" +VIDEO_DIR = ROOT_DIR / "videos" +for d in (LOG_DIR, DOWNLOAD_DIR, VIDEO_DIR): + d.mkdir(parents=True, exist_ok=True) + +# ==== 2. 日志配置 ==== +logger = logging.getLogger("bag_pipeline") +logger.setLevel(logging.DEBUG) +ch = logging.StreamHandler(sys.stdout) +ch.setLevel(logging.INFO) +ch.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) +fh = TimedRotatingFileHandler( + LOG_DIR / "pipeline.log", when="midnight", backupCount=7, encoding="utf-8" +) +fh.setLevel(logging.DEBUG) +fh.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s [%(processName)s] %(message)s") +) +logger.addHandler(ch) +logger.addHandler(fh) + +# ==== 3. 数据库连接工厂 ==== +config = ConfigManager() +DB_HOST = config.get("ROOT_DB_HOST", "localhost") +DB_PORT = config.get_int("ROOT_DB_PORT", 5432) +DB_NAME = config.get("ROOT_DB_NAME", "default_dbname") +DB_USER = config.get("ROOT_DB_USER", "default_user") +DB_PASSWORD = config.get("ROOT_DB_PASSWD", "default_password") + +DB_CFG = dict( + host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASSWORD +) + + +def get_conn(): + return psycopg2.connect(**DB_CFG) + + +def read_and_validate_csv(path): + required = {"bag name", "FST 1st node", "FST 2nd node", "FST 3rd node"} + records = [] + seen_bags = set() + with open(path, encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + headers = set(reader.fieldnames or []) + if not required.issubset(headers): + missing = required - headers + raise ValueError(f"CSV 缺少必要列: {missing}") + for i, row in enumerate(reader, start=2): + bag = row["bag name"].strip() + if not bag: + raise ValueError(f"第{i}行 bag name 为空") + if bag in seen_bags: + raise ValueError(f'第{i}行 bag 重复: "{bag}"') + seen_bags.add(bag) + nodes = [] + for col in ("FST 1st node", "FST 2nd node", "FST 3rd node"): + v = row.get(col, "").strip() + if v: + nodes.append(v) + if not nodes: + raise ValueError(f"第{i}行没有任何 FST 节点") + records.append({"bag": bag, "nodes": nodes, "first": nodes[0]}) + return records + + +@retry( + stop=stop_after_attempt(3), + wait=wait_fixed(5), + retry=retry_if_exception_type((OperationalError, CalledProcessError)), +) +def process_record(rec) -> bool: + bag, first = rec["bag"], rec["first"] + logger.info(f"[{bag}] 开始处理") + + # 5.1 查 data_path + try: + conn = get_conn() + with conn.cursor() as cur: + cur.execute( + "SELECT data_path FROM main_pangu WHERE bag_path LIKE %s LIMIT 1", + (f"%{bag}%",), + ) + row = cur.fetchone() + conn.close() + except Exception as e: + logger.error(f"[{bag}] 查询 data_path 出错: {e}") + raise + if not row: + logger.error(f"[{bag}] main_pangu 未找到记录,跳过") + return False + data_path = row[0] + + # 5.2 下载图片 + dst_dir = DOWNLOAD_DIR / bag / "camera_front_wide" + dst_dir.mkdir(parents=True, exist_ok=True) + src_path = data_path.partition("/")[2] + "/camera_front_wide/" + cmd_dl = ["coscmd", "download", "-r", src_path, str(dst_dir)] + try: + subprocess.run(cmd_dl, check=True, stdout=subprocess.DEVNULL) + except CalledProcessError as e: + logger.error(f"[{bag}] 下载失败: {e}") + raise + + # 5.3 生成 list.txt + imgs = sorted( + p.name for p in dst_dir.iterdir() if p.suffix.lower() in (".jpg", ".png") + ) + sampled = imgs[::3] + if not imgs: + logger.error(f"[{bag}] 未找到任何图片,跳过") + shutil.rmtree(DOWNLOAD_DIR / bag, ignore_errors=True) + return False + (dst_dir / "list.txt").write_text( + "\n".join(f'file "{fn}"' for fn in sampled), encoding="utf-8" + ) + + # 5.4 ffmpeg 合成 + out_dir = VIDEO_DIR / first + out_dir.mkdir(parents=True, exist_ok=True) + out_mp4 = out_dir / f"{bag}.mp4" + cmd_ff = [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + "list.txt", + "-vf", + "scale=640:360", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + str(out_mp4), + ] + try: + subprocess.run(cmd_ff, cwd=dst_dir, check=True) + except CalledProcessError as e: + logger.error(f"[{bag}] 合成失败: {e}") + raise + + # 5.5 更新数据库 + try: + conn = get_conn() + with conn: + with conn.cursor() as cur: + # 5.5.1 bag_list + cur.execute( + "SELECT id,fst_indexed FROM bag_list " + "WHERE name=%s AND is_deleted=FALSE AND is_active_data=TRUE", + (bag,), + ) + r = cur.fetchone() + if not r: + logger.error(f"[{bag}] bag_list 未找到,跳过更新") + conn.rollback() + return False + bag_id, fst_indexed = r + if not fst_indexed: + cur.execute( + "UPDATE bag_list SET fst_indexed=TRUE WHERE id=%s", (bag_id,) + ) + # 5.5.2 逐级关联 fst + parent = None + for name in rec["nodes"]: + cur.execute("SELECT id,parent_id FROM fst WHERE name=%s", (name,)) + fr = cur.fetchone() + if not fr: + logger.error(f"[{bag}] FST 节点不存在 '{name}',跳过") + conn.rollback() + return False + fid, actual_parent = fr + if parent is not None and actual_parent != parent: + logger.error( + f"[{bag}] 节点 '{name}' 父级校验失败 " + f"(actual={actual_parent}!=expect={parent}),跳过" + ) + conn.rollback() + return False + + # ---- 确保没插入过才 update bag_sum ---- + cur.execute( + "SELECT 1 FROM fst_bag WHERE bag_id=%s AND fst_node_id=%s", + (bag_id, fid), + ) + if not cur.fetchone(): + cur.execute( + "INSERT INTO fst_bag(bag_id,fst_node_id) VALUES(%s,%s)", + (bag_id, fid), + ) + cur.execute( + "UPDATE fst SET bag_sum = bag_sum + 1 WHERE id=%s", (fid,) + ) + parent = fid + except OperationalError as e: + logger.error(f"[{bag}] 更新DB连接错误: {e}") + raise + except Exception: + logger.exception(f"[{bag}] 更新DB时未知错误,跳过") + return False + finally: + if conn: + conn.close() + + # 5.6 清理 & 成功日志 + shutil.rmtree(DOWNLOAD_DIR / bag, ignore_errors=True) + logger.info(f"[{bag}] 处理成功") + return True + + +def main(): + parser = argparse.ArgumentParser(description="批量下载→合成→更新DB") + parser.add_argument("csv_path", help="CSV 文件路径") + parser.add_argument("-w", "--workers", type=int, default=4, help="并行 worker 数") + args = parser.parse_args() + + try: + records = read_and_validate_csv(args.csv_path) + except Exception as e: + logger.error(f"CSV 校验失败: {e}") + sys.exit(1) + + total = len(records) + logger.info(f"共 {total} 条,启动 {args.workers} 并行 worker") + + success = skipped = failed = 0 + with ProcessPoolExecutor(max_workers=args.workers) as exe: + fut2bag = {exe.submit(process_record, rec): rec["bag"] for rec in records} + for fut in tqdm(as_completed(fut2bag), total=total, desc="进度"): + bag = fut2bag[fut] + try: + ok = fut.result() + if ok: + success += 1 + else: + skipped += 1 + except Exception as e: + logger.error(f"[{bag}] 最终失败: {e}") + failed += 1 + + logger.info(f"结束:{success} 成功,{skipped} 跳过,{failed} 失败,共 {total}") + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/bag_geometry_distribution_plot.py b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_geometry_distribution_plot.py new file mode 100644 index 0000000..e6d8b03 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_geometry_distribution_plot.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +----------------------------------------------------------------------------------- +功能概述: + 把数据库里的rosbag的经纬度降采样, 提取, 画图生成每周采集覆盖度在SD map上的分布情况 + +使用示例: + + +配置说明: +----------------------------------------------------------------------------------- +""" + +import os +import re +import csv +import time +import math +import logging +import threading +import argparse +from io import StringIO +from http.server import HTTPServer, SimpleHTTPRequestHandler +from concurrent.futures import ThreadPoolExecutor, as_completed + +import numpy as np +import psycopg2 +from shapely import wkb +import folium +from folium import TileLayer, FeatureGroup, LayerControl +from branca.element import Element +from branca.colormap import linear +from qcloud_cos import CosConfig, CosS3Client +from tqdm import tqdm +from fst_data_pipeline.core.config_manager import ConfigManager + + +# ====== 配置区 ====== +config = ConfigManager() +DB_HOST = config.get("ROOT_DB_HOST", "localhost") +DB_PORT = config.get_int("ROOT_DB_PORT", 5432) +DB_NAME = config.get("ROOT_DB_NAME", "default_dbname") +DB_USER = config.get("ROOT_DB_USER", "default_user") +DB_PASSWORD = config.get("ROOT_DB_PASSWD", "default_password") + +COS_REGION = config.get("COS_CLIENT_REGION", "default_region") +COS_SECRET_ID = config.get("COS_CLIENT_SECRET_ID", "default_id") +COS_SECRET_KEY = config.get("COS_CLIENT_SECRET_KEY", "default_key") + +# ===== 日志配置 ===== +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s %(levelname)s [%(threadName)s] %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", +) +logger = logging.getLogger("gnss_map") + + +# ===== 静态文件服务 (带 CORS) ===== +class CORSHandler(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Access-Control-Allow-Origin", "*") + super().end_headers() + + +def serve_map(port: int, web_dir: str): + os.chdir(web_dir) + logger.info("Serving static files from %s", web_dir) + HTTPServer(("0.0.0.0", port), CORSHandler).serve_forever() + + +# ===== 工具:haversine 计算距离 ===== +def haversine(lat1, lon1, lat2, lon2): + R = 6371000.0 + φ1, φ2 = math.radians(lat1), math.radians(lat2) + dφ = math.radians(lat2 - lat1) + dλ = math.radians(lon2 - lon1) + a = math.sin(dφ / 2) ** 2 + math.cos(φ1) * math.cos(φ2) * math.sin(dλ / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ===== 单 bag 处理:下载→去重→下采样 → 返回 (name, pts_wkt) ===== +def process_bag(name, cos, bucket, prefix): + logger.debug("Start processing bag %s", name) + key = f"{prefix}/{name}.dir/raw_gnss.csv" + text = None + for attempt in range(1, 4): + try: + resp = cos.get_object(Bucket=bucket, Key=key) + text = resp["Body"].get_raw_stream().read().decode("utf-8") + logger.debug("[%s] downloaded %d bytes", name, len(text)) + break + except Exception as e: + logger.warning("[%s] download failed (attempt %d): %s", name, attempt, e) + time.sleep(1) + if not text: + logger.error("[%s] all download attempts failed → skip", name) + return None + + rows = list(csv.DictReader(StringIO(text))) + unique = [] + for r in rows: + try: + lat, lon, alt = float(r["lat"]), float(r["lon"]), float(r.get("alt", 0)) + except AttributeError: + continue + if not any(haversine(lat, lon, plat, plon) < 1.0 for plat, plon, _ in unique): + unique.append((lat, lon, alt)) + logger.debug("[%s] unique points: %d", name, len(unique)) + if not unique: + return None + + # 下采样最多30点 + if len(unique) > 30: + idx = np.linspace(0, len(unique) - 1, 30, dtype=int) + sampled = [unique[i] for i in idx] + else: + sampled = unique + logger.info("[%s] sampled %d points", name, len(sampled)) + + # 仅拼接 ST_MakePoint(...) 逗号列表,留给 SQL 拼 ARRAY[] + pts_wkt = ",".join(f"ST_MakePoint({lon},{lat},{alt})" for lat, lon, alt in sampled) + return name, pts_wkt + + +# ===== 主流程 ===== +def main(): + p = argparse.ArgumentParser() + p.add_argument("-p", "--map-port", type=int, default=8000) + p.add_argument("-u", "--tile-url", required=True) + p.add_argument("-w", "--workers", type=int, default=8) + args = p.parse_args() + + # COS client + cos = CosS3Client( + CosConfig( + Region=COS_REGION, + SecretId=COS_SECRET_ID, + SecretKey=COS_SECRET_KEY, + ) + ) + BUCKET = config.get("bucket", "b-perception-e2e-1318950322") + PREFIX = config.get("prefix", "mb_raw_rosbag_decode_dirs") + + # DB 配置 + DB_CONF = dict( + host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASSWORD + ) + + # 1. 取待处理 bag + logger.info("Fetch unprocessed bags from DB") + with psycopg2.connect(**DB_CONF) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT bl.name + FROM bag_list bl + LEFT JOIN geometry_info gi ON gi.rosbag_name=bl.name + WHERE bl.is_decoded=TRUE + AND gi.rosbag_name IS NULL + """ + ) + names = [r[0] for r in cur.fetchall()] + logger.info("To process: %d bags", len(names)) + + # 2. 并行下载 & 处理 + results = [] + with ThreadPoolExecutor(max_workers=args.workers) as ex: + futures = {ex.submit(process_bag, n, cos, BUCKET, PREFIX): n for n in names} + for f in tqdm(as_completed(futures), total=len(names), desc="Processing"): + n = futures[f] + try: + r = f.result() + if r: + results.append(r) + except Exception as e: + logger.error("[%s] error: %s", n, e) + logger.info("Valid processed bags: %d", len(results)) + if not results: + logger.warning("No new data → skip insertion, proceed to visualization") + + # 3. 批量插入 geometry_info + if results: + logger.info("Batch inserting into geometry_info") + with psycopg2.connect(**DB_CONF) as conn: + with conn.cursor() as cur: + batch = 50 + for i in range(0, len(results), batch): + chunk = results[i : i + batch] + # 手工拼 VALUES 列表 + vals = [] + for name, pts in chunk: + esc = name.replace("'", "''") + vals.append(f"('{esc}', ARRAY[{pts}]::geometry(PointZ)[])") + sql = f""" + INSERT INTO geometry_info(rosbag_name,gnss_downsampled_points) + VALUES {",".join(vals)} + ON CONFLICT(rosbag_name) DO NOTHING + """ + cur.execute(sql) + conn.commit() + logger.info( + "Inserted batch %d~%d", i + 1, min(i + batch, len(results)) + ) + logger.info("Batch insert done, total: %d", len(results)) + + # 4. 从 DB 读取所有 geometry + logger.info("Load all geometries from DB") + with psycopg2.connect(**DB_CONF) as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT rosbag_name, gnss_downsampled_points FROM geometry_info" + ) + rows = cur.fetchall() + + by_bag, all_pts = {}, [] + for name, arr in tqdm(rows, desc="Loading geometry"): + pts = [] + for hw in arr.strip("{}").split(","): + h = re.sub(r"[^0-9A-Fa-f]", "", hw) + p = wkb.loads(bytes.fromhex(h)) + pts.append((p.y, p.x)) + all_pts.append((p.y, p.x)) + if pts: + by_bag[name] = pts + + if all_pts: + lats = [p[0] for p in all_pts] + lons = [p[1] for p in all_pts] + center = (sum(lats) / len(lats), sum(lons) / len(lons)) + else: + logger.warning("No points in DB → default center (0,0)") + center = (0.0, 0.0) + logger.info("Map center: %s", center) + + # 5. Folium 地图 & 两个图层(视图) + m = folium.Map( + location=center, + zoom_start=6, + tiles=None, + prefer_canvas=True, + control_scale=True, + zoom_control=True, + ) + TileLayer(tiles=args.tile_url, overlay=True, control=False, attr=" ").add_to(m) + + header = m.get_root().header + for tag in [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]: + header.add_child(Element(tag)) + + # 轨迹视图 + fg1 = FeatureGroup(name="Trajectory", overlay=False, show=True) + for pts in by_bag.values(): + folium.PolyLine(pts, color="#3388ff", weight=1, opacity=0.7).add_to(fg1) + m.add_child(fg1) + + # 聚类点视图 + cnt = {} + for lat, lon in all_pts: + cnt[(lat, lon)] = cnt.get((lat, lon), 0) + 1 + if cnt: + mi, ma = min(cnt.values()), max(cnt.values()) + cmap = linear.YlOrRd_09.scale(mi, ma) + cmap.caption = "Point Count" + else: + cmap = None + fg2 = FeatureGroup(name="Cluster", overlay=False, show=False) + for (lat, lon), c in cnt.items(): + color = cmap(c) if cmap else "#000000" + folium.CircleMarker( + location=(lat, lon), + radius=1 + min(c, 4), + color=color, + fill=True, + fill_color=color, + fill_opacity=0.7, + popup=f"count: {c}", + ).add_to(fg2) + m.add_child(fg2) + if cmap: + m.add_child(cmap) + + LayerControl(collapsed=False).add_to(m) + + # 6. 保存并启动静态服务 + script_dir = os.path.dirname(__file__) + html = os.path.join(script_dir, "map.html") + m.save(html) + logger.info("Saved map → %s", html) + + threading.Thread( + target=serve_map, + args=(args.map_port, script_dir), + daemon=True, + name="HTTP-Server", + ).start() + logger.info("Server running at http://127.0.0.1:%d/map.html", args.map_port) + threading.Event().wait() + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/bag_index_to_db.py b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_index_to_db.py new file mode 100644 index 0000000..4034aa8 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_index_to_db.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""_summary_ +✅ 批量读取 ROS bag 文件列表(一个 txt 文件里每行是文件路径) +✅ 根据文件名解析出车辆、时间、bag 名称 +✅ 自动: +在数据库里插入 / 更新 bag_list(记录名称与 project_id) +插入 / 更新 main_pangu(记录 bag 的元信息) +插入 / 更新 secondary_pangu(记录 COS 上的文件、目录存在性及链接) +读取 meta_info.txt 文件里的 topic 列表, 更新 topic_list 与 bag_topic 关联表 +✅ 使用腾讯云 COS (对象存储) API 检查文件、目录是否存在, 并提供文件访问链接 +✅ 支持多线程并发处理, 加快大量 bag 的同步速度 +✅ 自动维护数据库连接池 (psycopg2 ThreadedConnectionPool) +✅ 日志文件记录所有操作, 包括成功、跳过、失败, 便于后续排查 +""" + +import os +import re +import tempfile +import logging +import argparse +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed +from psycopg2.pool import ThreadedConnectionPool +from tqdm import tqdm +from qcloud_cos import CosConfig, CosS3Client +from qcloud_cos.cos_exception import CosServiceError +from fst_data_pipeline.core.config_manager import ConfigManager + +# ====== 配置区 ====== +config = ConfigManager() +DB_HOST = config.get("ROOT_DB_HOST", "localhost") +DB_PORT = config.get_int("ROOT_DB_PORT", 5432) +DB_NAME = config.get("ROOT_DB_NAME", "default_dbname") +DB_USER = config.get("ROOT_DB_USER", "default_user") +DB_PASSWORD = config.get("ROOT_DB_PASSWD", "default_password") + +COS_REGION = config.get("COS_CLIENT_REGION", "default_region") +COS_SECRET_ID = config.get("COS_CLIENT_SECRET_ID", "default_id") +COS_SECRET_KEY = config.get("COS_CLIENT_SECRET_KEY", "default_key") + +BUCKET = config.get("COS_BUCKET", "b-perception-e2e-1318950322") +config.require("COS_PREFIX_DECODE") +PREFIX = config.get("COS_PREFIX_DECODE", "mb_raw_rosbag_decode_dirs") +DOMAIN = f"https://{BUCKET}.cos.{COS_REGION}.myqcloud.com" + +MIN_CONN, MAX_CONN = 1, 10 +MAX_WORKERS = 8 # 并发线程数, 可调 + +# ====== 日志 ====== +LOGFILE = f"{datetime.now():%Y-%m-%d}.log" +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[logging.StreamHandler(), logging.FileHandler(LOGFILE, mode="a")], +) +logger = logging.getLogger() + +# ====== COS 客户端 ====== +cos = CosS3Client( + CosConfig(Region=COS_REGION, SecretId=COS_SECRET_ID, SecretKey=COS_SECRET_KEY) +) + +# ====== 全局 DB 连接池 ====== +pool = ThreadedConnectionPool( + MIN_CONN, MAX_CONN, host=DB_HOST, dbname=DB_NAME, user=DB_USER, password=DB_PASSWORD +) + +# ====== 工具函数 ====== + + +def get_db(): + """从池里取一个 conn, 设置 autocommit=True""" + conn = pool.getconn() + conn.autocommit = True + return conn, conn.cursor() + + +def release_db(conn, cur): + cur.close() + pool.putconn(conn) + + +def parse_rosbag_line(line): + raw = line.strip() + name = os.path.basename(raw).split("/")[0] + if not name.endswith(".bag"): + name += ".bag" + m = re.search(r"_(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})_", name) + if not m: + raise ValueError(f"无法解析 rosbag 名称/时间: {name}") + yy, mm, dd, HH, MN, SS = m.groups() + v = name[:8] + dt = datetime.strptime(f"{yy}-{mm}-{dd} {HH}:{MN}:{SS}", "%Y-%m-%d %H:%M:%S") + return name, { + "name": name, + "vehicle": v, + "datetime": dt, + "path": f"{DOMAIN}/mb_cuct_data_collection/{name}", + "data_path": f"{DOMAIN}/{PREFIX}{name}.dir", + } + + +def download_txt(name): + key = f"{name}.meta_info.txt" + try: + obj = cos.get_object(Bucket=BUCKET, Key=key) + except CosServiceError: + return None + fd, tmp = tempfile.mkstemp(prefix=name + "_", suffix=".txt") + os.close(fd) + with open(tmp, "wb") as f: + for chunk in obj["Body"].get_raw_stream(): + f.write(chunk) + return tmp + + +def parse_rosbag_info(fp): + tops = [] + on = False + with open(fp, "r", encoding="utf-8") as f: + for L in f: + if L.startswith("topics:"): + on = True + continue + if on: + s = L.strip() + if not s: + break + p = s.split() + if len(p) > 3: + tops.append((p[0], " ".join(p[3:]).rstrip(":"))) + return tops + + +# ==== 读取已成功处理的 bag_name 列表 ==== + + +def load_done(logfile): + done = set() + if not os.path.exists(logfile): + return done + pat = re.compile(r"SUCCESS\s+(.+\.bag)\b") + with open(logfile, "r", encoding="utf-8") as f: + for L in f: + m = pat.search(L) + if m: + done.add(m.group(1)) + return done + + +# ==== 单条处理逻辑 ==== + + +def process_one(line): + try: + bag_name, ent = parse_rosbag_line(line) + except ValueError as e: + logger.error(e) + return + + conn, cur = get_db() + try: + # 1) bag_list + cur.execute( + "INSERT INTO bag_list(name,project_id) VALUES(%s,%s) " + "ON CONFLICT(name) DO UPDATE SET update_time=NOW() " + "RETURNING id", + (bag_name, 1), + ) + bag_id = cur.fetchone()[0] + + # 2) main_pangu + cur.execute( + "INSERT INTO main_pangu(bag_id,name,vehicle,datetime,path,data_path) " + "VALUES(%s,%s,%s,%s,%s,%s) " + "ON CONFLICT(bag_id) DO UPDATE SET " + "name=COALESCE(main_pangu.name,EXCLUDED.name)," + "vehicle=COALESCE(main_pangu.vehicle,EXCLUDED.vehicle)," + "datetime=COALESCE(main_pangu.datetime,EXCLUDED.datetime)," + "path=COALESCE(main_pangu.path,EXCLUDED.path)," + "data_path=COALESCE(main_pangu.data_path,EXCLUDED.data_path)", + ( + bag_id, + ent["name"], + ent["vehicle"], + ent["datetime"], + ent["path"], + ent["data_path"], + ), + ) + + # 3) secondary_pangu + file_fs = ["raw_gnss.csv", "raw_imu.csv", "ego_motion.csv", "vehicle_wheel.csv"] + dir_fs = [ + "camera_front_wide", + "lidar_gt_pandar128", + "object_lidar_gt_pandar128_manual", + "lidar_fd_multi_scan_raw", + "camera_fisheye_left", + "camera_fisheye_right", + "calibration", + ] + data = {} + for f in file_fs: + key = f"{PREFIX}{bag_name}.dir/{f}" + try: + cos.head_object(Bucket=BUCKET, Key=key) + data[f] = f"{DOMAIN}/{key}" + except CosServiceError: + data[f] = None + for d in dir_fs: + pfx = f"{PREFIX}{bag_name}.dir/{d}/" + try: + r = cos.list_objects_v2(Bucket=BUCKET, Prefix=pfx, MaxKeys=1) + data[d] = f"{DOMAIN}/{pfx}" if r.get("Contents") else None + except CosServiceError: + data[d] = None + data["opt"] = {} + cols = ",".join(["bag_id"] + file_fs + dir_fs + ["opt"]) + vals = [bag_id] + [data[x] for x in file_fs + dir_fs] + [data["opt"]] + upd = ( + ",".join( + f"{x}=COALESCE(secondary_pangu.{x},EXCLUDED.{x})" + for x in file_fs + dir_fs + ) + + ",opt=EXCLUDED.opt" + ) + cur.execute( + f"INSERT INTO secondary_pangu({cols}) VALUES({','.join(['%s'] * len(vals))}) " + f"ON CONFLICT(bag_id) DO UPDATE SET {upd}", + vals, + ) + + # 4) topics + 关联 + txt = download_txt(bag_name) + if txt: + tops = parse_rosbag_info(txt) + os.remove(txt) + for nm, ty in tops: + cur.execute( + "INSERT INTO topic_list(name,type) VALUES(%s,%s) " + "ON CONFLICT(name) DO UPDATE " + "SET type=COALESCE(topic_list.type,EXCLUDED.type),update_time=NOW() " + "RETURNING id", + (nm, ty), + ) + tid = cur.fetchone()[0] + cur.execute( + "INSERT INTO bag_topic(bag_id,topic_id) VALUES(%s,%s) " + "ON CONFLICT DO NOTHING", + (bag_id, tid), + ) + + logger.info("SUCCESS %s", bag_name) + + except Exception: + logger.exception("FAIL %s", bag_name) + finally: + release_db(conn, cur) + + +# ==== 主入口 ==== + + +def main(txtfile, threads): + done = load_done(LOGFILE) + lines = [L for L in open(txtfile, "r") if L.strip()] + tasks = [] + with ThreadPoolExecutor(max_workers=threads) as ex: + for L in lines: + try: + nm = os.path.basename(L.strip()) + if not nm.endswith(".bag"): + nm += ".bag" + except FileExistsError: + continue + if nm in done: + logger.info("SKIP %s", nm) + continue + tasks.append(ex.submit(process_one, L)) + + for _ in tqdm(as_completed(tasks), total=len(tasks), desc="Bags"): + pass + + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument("file", help="ROS bag 列表 txt") + p.add_argument("--threads", type=int, default=MAX_WORKERS) + args = p.parse_args() + main(args.file, args.threads) diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/bag_scan_gt_lidar_missing.py b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_scan_gt_lidar_missing.py new file mode 100644 index 0000000..04450c0 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_scan_gt_lidar_missing.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import psycopg2 +from qcloud_cos import CosConfig, CosS3Client +from fst_data_pipeline.core.config_manager import ConfigManager + +# ====== 配置区 ====== + +config = ConfigManager() +DB_HOST = config.get("ROOT_DB_HOST", "localhost") +DB_PORT = config.get_int("ROOT_DB_PORT", 5432) +DB_NAME = config.get("ROOT_DB_NAME", "default_dbname") +DB_USER = config.get("ROOT_DB_USER", "default_user") +DB_PASSWORD = config.get("ROOT_DB_PASSWD", "default_password") + +COS_REGION = config.get("COS_CLIENT_REGION", "default_region") +COS_SECRET_ID = config.get("COS_CLIENT_SECRET_ID", "default_id") +COS_SECRET_KEY = config.get("COS_CLIENT_SECRET_KEY", "default_key") + +BUCKET = config.get("COS_BUCKET", "b-perception-e2e-1318950322") +config.require("COS_PREFIX_DECODE") +PREFIX = config.get("COS_PREFIX_DECODE", "mb_raw_rosbag_decode_dirs") +DOMAIN = f"https://{BUCKET}.cos.{COS_REGION}.myqcloud.com" + + +# 输出缺失列表的文件 +MISSING_FILE = "missing.txt" + + +def fetch_paths(): + conn = psycopg2.connect( + host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASSWORD + ) + with conn.cursor() as cur: + cur.execute("SELECT data_path FROM bag_list;") + paths = [row[0] for row in cur.fetchall()] + conn.close() + return paths + + +def check_dirs(paths): + cfg = CosConfig( + Secret_id=COS_SECRET_ID, Secret_key=COS_SECRET_KEY, Region=COS_REGION + ) + client = CosS3Client(cfg) + exist, missing = [], [] + for p in paths: + prefix = p.rstrip("/") + "/lidar_gt_pandar128/" + resp = client.list_objects(Bucket=BUCKET, Prefix=prefix, MaxKeys=1) + if resp.get("Contents"): + exist.append(p) + else: + missing.append(p) + return exist, missing + + +def write_missing(missing, filepath): + with open(filepath, "w", encoding="utf-8") as f: + for p in missing: + f.write(p + "\n") + + +def main(): + paths = fetch_paths() + exist, missing = check_dirs(paths) + + print(f"总路径数: {len(paths)}") + print(f"存在 lidar_gt_pandar128 的: {len(exist)}") + for p in exist: + print(" [OK] ", p) + + print(f"\n缺失 lidar_gt_pandar128 的: {len(missing)}") + for p in missing: + print(" [MISS]", p) + + # 将缺失列表写入文件 + write_missing(missing, MISSING_FILE) + print(f"\n已将 {len(missing)} 条缺失记录写入 `{MISSING_FILE}`") + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/bag_scanner.py b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_scanner.py new file mode 100644 index 0000000..efd57c0 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_scanner.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" +四种互斥模式: + (默认) —— 全量扫描 + file --tasks-file—— 指定文件 + service —— HTTP 服务 + check —— fst_bag 关联并推送缺失 +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import List, Tuple, Dict, Any + +import psycopg2 +import requests +from flask import Flask, jsonify, request +from qcloud_cos import CosConfig, CosS3Client + +from fst_data_pipeline.core.config_manager import ConfigManager + +# ---------- 配置 ---------- +cfg = ConfigManager() + +DB = { + "host": cfg.get("ROOT_DB_HOST"), + "port": cfg.get_int("ROOT_DB_PORT"), + "dbname": cfg.get("ROOT_DB_NAME"), + "user": cfg.get("ROOT_DB_USER"), + "password": cfg.get("ROOT_DB_PASSWD"), +} + +COS_CFG = CosConfig( + Region=cfg.get("COS_CLIENT_REGION"), + SecretId=cfg.get("COS_CLIENT_SECRET"), + SecretKey=cfg.get("COS_CLIENT_KEY"), +) +BUCKET = cfg.get("COS_BUCKET") + +LD_READY = cfg.get("LD_READY_URL", "http://ld-checker/ready") +OD_READY = cfg.get("OD_READY_URL", "http://od-checker/ready") +LD_NOTIFY = cfg.get("LD_NOTIFY_URL", "http://ld-checker/notify") +OD_NOTIFY = cfg.get("OD_NOTIFY_URL", "http://od-checker/notify") + +LOCAL_API_HOST = cfg.get("ROOT_DB_API", "http://10.0.240.4:5232") + +MAX_WORKERS = 16 +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(message)s") +log = logging.getLogger("bag") + +# ---------- 类型 ---------- +BagRow = Tuple[ + int, str, int, bool, int, int +] # id, name, project_id, is_decoded, od_old, ld_old + + +# ---------- 工具 ---------- +def pg_conn(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn, conn.cursor() + + +def cos_client() -> CosS3Client: + return CosS3Client(COS_CFG) + + +def cos_exists(cli: CosS3Client, prefix: str) -> bool: + try: + return bool( + cli.list_objects(Bucket=BUCKET, Prefix=prefix, MaxKeys=1).get("Contents") + ) + except Exception: + return False + + +# ---------- HTTP ---------- +def _get_all_fst_bags() -> List[str]: + url = f"{LOCAL_API_HOST}/api/fst/baglist" + try: + r = requests.get(url, timeout=10) + r.raise_for_status() + return r.json() + except Exception as e: + log.error("获取 fst bag list 失败: %s", e) + return [] + + +def _resolve_paths(bags: List[str]) -> Dict[str, str]: + url = f"{LOCAL_API_HOST}/api/bags/pangu/detail" + if not bags: + return {} + try: + r = requests.post(url, json=bags, timeout=30) + r.raise_for_status() + return { + name: info["data_path"].split("/", 1)[-1] + for name, info in r.json().items() + if info.get("data_path") + } + except Exception as e: + log.error("解析 bag path 失败: %s", e) + return {} + + +# ---------- 推送 ---------- +MISSING_LD: List[str] = [] +MISSING_OD: List[str] = [] + + +def push_list(ready_url: str, notify_url: str, bag_list: List[str], kind: str) -> None: + if not bag_list: + return + path_map = _resolve_paths(bag_list) + if not path_map: + log.warning("无可推送的 %s 路径", kind) + return + paths = [path_map[name] for name in bag_list if name in path_map] + + log.info("%s: %d bags → pushing %d paths", kind, len(bag_list), len(paths)) + while True: + try: + if requests.get(ready_url, timeout=5).json().get("ready"): + break + except Exception as e: + log.warning("ready check failed: %s", e) + time.sleep(2) + try: + resp = requests.post(notify_url, json=paths, timeout=30) + resp.raise_for_status() + log.info("%s pushed %d paths, status=%s", kind, len(paths), resp.status_code) + except Exception as e: + log.error("%s push failed: %s", kind, e) + + +# ---------- 核心处理 ---------- +def process_row(row: BagRow, collect_missing: bool = False) -> None: + bag_id, name, project_id, _, od_old, ld_old = row + + # 提前检查是否已“完美” + conn, cur = pg_conn() + try: + cur.execute("SELECT reserved_str FROM main_pangu WHERE name=%s", (name,)) + reserved_str = (cur.fetchone() or ("",))[0] + has_md5 = reserved_str.startswith("md5:") + if ld_old == 3 and od_old == 3 and has_md5: + log.debug("[%s] 已完整,跳过", name) + return + finally: + conn.close() + + # 需要真正检查 COS + conn, cur = pg_conn() + cli = cos_client() + try: + base = "mb" if project_id == 1 else "mmt" + derived = f"{base}_raw_rosbag_decode_dirs/{name}.dir/derived/" + + ld_auto = cos_exists(cli, derived + "LDGT/") + ld_man = cos_exists(cli, derived + "LD_manual/") + od_auto = cos_exists(cli, derived + "object_auto_labeling/") + od_man = cos_exists(cli, derived + "object_manual_labeling/") + + def calc_flag(a: bool, m: bool) -> int: + return 3 if a and m else 2 if m else 1 if a else 0 + + ld_new = calc_flag(ld_auto, ld_man) + od_new = calc_flag(od_auto, od_man) + + # 更新状态 + for field, old, new in ( + ("ld_annotated", ld_old, ld_new), + ("od_annotated", od_old, od_new), + ): + if old != new: + cur.execute( + f"UPDATE bag_list SET {field}=%s WHERE id=%s", (new, bag_id) + ) + log.info("[%s] %s %s→%s", name, field, old, new) + + # 处理 md5 + if not has_md5: + cur.execute("SELECT bag_path FROM main_pangu WHERE name=%s", (name,)) + bag_path = (cur.fetchone() or ("",))[0] + if bag_path: + cos_key = bag_path.lstrip("/") + tmp_path = f"/tmp/{name}.bag" + try: + cli.download_file(Bucket=BUCKET, Key=cos_key, DestFilePath=tmp_path) + md5 = subprocess.check_output( + ["md5sum", tmp_path], text=True + ).split()[0] + os.remove(tmp_path) + except Exception as e: + log.warning("[%s] 下载/计算 md5 失败: %s", name, e) + md5 = None + + if md5: + cur.execute( + "UPDATE main_pangu SET reserved_str='md5:'||%s WHERE name=%s", + (md5, name), + ) + cur.execute( + "SELECT reserved_json FROM main_pangu WHERE name=%s", (name,) + ) + reserved_json: Dict[str, Any] = json.loads( + cur.fetchone()[0] or "{}" + ) + reserved_json.update({ + "bag_meta": f"https://cla-dev.ca4ad.com/cdi/data/{md5}", + "bag_player": f"https://mviz-dev.ca4ad.com/player/v4/?bag_md5={md5}", + }) + cur.execute( + "UPDATE main_pangu SET reserved_json=COALESCE(reserved_json,'{}'::jsonb)||%s WHERE name=%s", + (json.dumps(reserved_json, ensure_ascii=False), name), + ) + log.info("[%s] 写入 md5:%s", name, md5) + + # 收集缺失 + if collect_missing: + if not ld_auto: + MISSING_LD.append(name) + if not od_auto: + MISSING_OD.append(name) + finally: + conn.close() + + +# ---------- 数据获取 ---------- +def fetch_rows_from_file(path: Path) -> List[BagRow]: + with open(path, encoding="utf-8") as f: + names = [l.strip() for l in f if l.strip()] + if not names: + return [] + conn, cur = pg_conn() + cur.execute("SELECT * FROM bag_list WHERE name = ANY(%s)", (names,)) + rows = cur.fetchall() + conn.close() + return rows + + +def fetch_rows_from_all() -> List[BagRow]: + conn, cur = pg_conn() + cur.execute("SELECT * FROM bag_list") + rows = cur.fetchall() + conn.close() + return rows + + +def fetch_rows_from_fst() -> List[BagRow]: + bag_names = _get_all_fst_bags() + if not bag_names: + return [] + conn, cur = pg_conn() + cur.execute("SELECT * FROM bag_list WHERE name = ANY(%s)", (bag_names,)) + rows = cur.fetchall() + conn.close() + log.info("fst_bag 关联 %d 条", len(rows)) + return rows + + +# ---------- 模式封装 ---------- +def _run(rows: List[BagRow], collect_missing: bool): + MISSING_LD.clear() + MISSING_OD.clear() + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex: + list(ex.map(lambda r: process_row(r, collect_missing), rows)) + if collect_missing: + push_list(LD_READY, LD_NOTIFY, MISSING_LD, "LD") + push_list(OD_READY, OD_NOTIFY, MISSING_OD, "OD") + + +def run_all(): + rows = fetch_rows_from_all() + _run(rows, collect_missing=False) + log.info("全量扫描完成") + + +def run_file(path: Path): + rows = fetch_rows_from_file(path) + _run(rows, collect_missing=False) + log.info("file 模式完成") + + +def run_service(host: str, port: int): + app = Flask(__name__) + + @app.route("/ready") + def ready(): + return jsonify(ready=True) + + def handle_notify(bags: List[str]): + rows = [] + if bags: + conn, cur = pg_conn() + cur.execute("SELECT * FROM bag_list WHERE name = ANY(%s)", (bags,)) + rows = cur.fetchall() + conn.close() + for r in rows: + process_row(r, collect_missing=False) + + @app.route("/notify_ld", methods=["POST"]) + def notify_ld(): + bags = request.get_json(force=True) + if not isinstance(bags, list): + return jsonify(error="need list"), 400 + handle_notify(bags) + return jsonify(status="accepted"), 202 + + @app.route("/notify_od", methods=["POST"]) + def notify_od(): + bags = request.get_json(force=True) + if not isinstance(bags, list): + return jsonify(error="need list"), 400 + handle_notify(bags) + return jsonify(status="accepted"), 202 + + app.run(host=host, port=port, threaded=True) + + +def run_check(): + rows = fetch_rows_from_fst() + _run(rows, collect_missing=True) + log.info("check 模式完成") + + +# ---------- CLI ---------- +if __name__ == "__main__": + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="mode", help="运行模式") + parser.set_defaults(mode="all") + f = sub.add_parser("file", help="读取 bag 列表文件") + f.add_argument("--tasks-file", required=True) + s = sub.add_parser("service", help="启动 HTTP 服务") + s.add_argument("--host", default="0.0.0.0") + s.add_argument("--port", type=int, default=8000) + sub.add_parser("check", help="仅扫描 fst_bag 关联 bag 并推送缺失") + + args = parser.parse_args() + if args.mode == "file": + run_file(Path(args.tasks_file)) + elif args.mode == "service": + run_service(args.host, args.port) + elif args.mode == "check": + run_check() + else: + run_all() diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/bag_sync_decode_status.py b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_sync_decode_status.py new file mode 100644 index 0000000..2d7d8aa --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/bag_sync_decode_status.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +----------------------------------------------------------------------------------- +功能概述: + 该脚本用于同步腾讯云 COS(对象存储)上的原始 .bag 文件与 PostgreSQL 中的 bag_list 表。 + 同时根据 COS 上解码输出目录 b/ 下的 .dir 文件, 更新数据库中对应记录的解码状态。 + 支持在数据库同步完成后, 根据 --notify 参数轮询后端服务是否就绪(通过 /ready 接口), + 并在就绪后将未解码文件列表 POST 到 /notify 接口, 驱动后续解码或处理流程。 + +主要流程: + 1. 连接腾讯云 COS, 列出 a/ 前缀下所有 .bag 文件, 插入数据库(跳过已存在)。 + 2. 查询数据库中未解码且未删除的记录。 + 3. 列出 b/ 前缀下的 .dir 文件, 推断已解码的 .bag 文件, 批量更新数据库标记 is_decoded=TRUE。 + 4. 如果指定 --notify: + - 轮询 /ready 接口直到返回 { "ready": true } + - 再将剩余未解码文件列表(带前缀)POST 到 /notify 接口。 + +依赖环境: + - Python 3.6+ + - psycopg2 (PostgreSQL 驱动) + - requests (HTTP 请求) + - qcloud_cos (腾讯云 COS Python SDK) + +使用示例: + python3 cos_pg_sync.py + python3 cos_pg_sync.py --notify + +配置说明: + 可在脚本开头直接修改: + COS_SECRET_ID / COS_SECRET_KEY / COS_REGION / COS_BUCKET + PostgreSQL 连接信息 PG_HOST 等 + GATE_URL / NOTIFY_URL 等服务端接口地址 +----------------------------------------------------------------------------------- +""" + +import os +import time +import argparse +import logging +import psycopg2 +from psycopg2.extras import execute_values +import requests +from qcloud_cos import CosConfig, CosS3Client +from fst_data_pipeline.core.config_manager import ConfigManager + +# ====== 配置区 ====== +config = ConfigManager() +DB_HOST = config.get("ROOT_DB_HOST", "localhost") +DB_PORT = config.get_int("ROOT_DB_PORT", 5432) +DB_NAME = config.get("ROOT_DB_NAME", "default_dbname") +DB_USER = config.get("ROOT_DB_USER", "default_user") +DB_PASSWORD = config.get("ROOT_DB_PASSWD", "default_password") + +COS_REGION = config.get("COS_CLIENT_REGION", "default_region") +COS_SECRET_ID = config.get("COS_CLIENT_SECRET_ID", "default_id") +COS_SECRET_KEY = config.get("COS_CLIENT_SECRET_KEY", "default_key") +BUCKET = config.get("COS_BUCKET", "b-perception-e2e-1318950322") + +config.require("COS_PREFIX_DECODE") +DECODED_PREFIX = config.get("COS_PREFIX_DECODE", "mb_raw_rosbag_decode_dirs") +RAW_BAG_PREFIX = config.get("COS_PREFIX_BAG", "mb_cuct_data_collection") + +DOMAIN = f"https://{BUCKET}.cos.{COS_REGION}.myqcloud.com" + + +# Service 模式的 HTTP 接口 +GATE_URL = "https://example.com/ready" +NOTIFY_URL = "https://example.com/notify" +LOG_FILE = "sync.log" + + +def init_logger(): + fmt = "%(asctime)s [%(levelname)s] %(message)s" + logging.basicConfig( + level=logging.INFO, + format=fmt, + handlers=[ + logging.StreamHandler(), + logging.FileHandler(LOG_FILE, encoding="utf-8"), + ], + ) + + +def get_cos_client(): + cfg = CosConfig(Region=COS_REGION, SecretId=COS_SECRET_ID, SecretKey=COS_SECRET_KEY) + return CosS3Client(cfg) + + +def list_objs(cos, prefix): + """分页列出 COS 上指定前缀下的所有对象 key""" + keys = [] + token = None + while True: + params = { + "Bucket": BUCKET, + "Prefix": prefix, + "MaxKeys": 1000, + } + if token: + params["ContinuationToken"] = token + resp = cos.list_objects_v2(**params) + for obj in resp.get("Contents", []): + keys.append(obj["Key"]) + if resp.get("IsTruncated"): + token = resp["NextContinuationToken"] + else: + break + return keys + + +def wait_for_ready(): + """轮询 /ready, 直到返回 JSON { "ready": true }""" + logging.info(f"开始轮询 Gate URL: {GATE_URL}") + while True: + try: + r = requests.get(GATE_URL, timeout=10) + if r.status_code == 200: + data = r.json() + if isinstance(data, dict) and data.get("ready") is True: + logging.info("Gate 就绪, 开始通知流程") + return + logging.info( + "Gate 未就绪(HTTP %d 或 ready=false), 10 分钟后重试", r.status_code + ) + except Exception as e: + logging.warning(f"轮询 Gate 时发生异常: {e}") + time.sleep(600) # 10 分钟后重试 + + +def main(): + init_logger() + + p = argparse.ArgumentParser( + description="同步 COS 与 bag_list, 并可选通知未解码列表" + ) + p.add_argument( + "--notify", + "-n", + action="store_true", + help="轮询 /ready, 再 POST 剩余未解码列表(带前缀)", + ) + args = p.parse_args() + + # 1. 初始化 COS 客户端和数据库连接 + cos = get_cos_client() + conn = psycopg2.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, dbname=DB_NAME + ) + cur = conn.cursor() + + # —— STEP1:同步 a/ 下的 .bag 到 bag_list(name, project_id) —— + keys_a = list_objs(cos, RAW_BAG_PREFIX) + # 提取所有 .bag 的名字(不含前缀) + names = [os.path.basename(k) for k in keys_a if k.lower().endswith(".bag")] + if names: + # 默认 project_id=1 + records = [(n, 1) for n in names] + execute_values( + cur, + "INSERT INTO bag_list(name, project_id) VALUES %s " + "ON CONFLICT(name) DO NOTHING", + records, + ) + logging.info(f"STEP1: 尝试插入 {len(records)} 条记录, 冲突则跳过") + else: + logging.info("STEP1: 未发现 .bag 文件, 无需插入") + + # —— STEP2:查询所有 is_decoded=FALSE 且 is_deleted=FALSE 的 name —— + cur.execute("SELECT name FROM bag_list WHERE is_decoded=FALSE AND is_deleted=FALSE") + undecoded = [row[0] for row in cur.fetchall()] + logging.info(f"STEP2: 当前未解码记录数:{len(undecoded)}") + + # —— STEP3:扫描 b/ 下 .dir, 并批量更新 is_decoded —— + keys_b = list_objs(cos, DECODED_PREFIX) + # available 是所有已解码的 bag 名字(带 .bag 后缀) + available = { + os.path.basename(k)[:-4] + ".bag" for k in keys_b if k.lower().endswith(".dir") + } + to_update = list(set(undecoded) & available) + if to_update: + cur.execute( + "UPDATE bag_list " + "SET is_decoded=TRUE, update_time=CURRENT_TIMESTAMP " + "WHERE name = ANY(%s)", + (to_update,), + ) + logging.info(f"STEP3: 标记已解码 {len(to_update)} 条记录") + else: + logging.info("STEP3: 无需更新解码状态") + + conn.commit() + + # —— 可选通知流程 —— + if args.notify: + # 1) 等待服务端就绪 + wait_for_ready() + + # 2) 再次查询剩余未解码名称 + cur.execute( + "SELECT name FROM bag_list WHERE is_decoded=FALSE AND is_deleted=FALSE" + ) + remaining = [row[0] for row in cur.fetchall()] + logging.info(f"通知前剩余未解码 {len(remaining)} 条: {remaining}") + + # 3) 补上前缀, POST 给 /notify + paths = [RAW_BAG_PREFIX + name for name in remaining] + try: + resp = requests.post(NOTIFY_URL, json=paths, timeout=10) + logging.info(f"POST /notify 返回 HTTP {resp.status_code}: {resp.text}") + except Exception as e: + logging.error(f"通知失败: {e}") + + # 清理资源 + cur.close() + conn.close() + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/bag_operation/merge_rosbag.py b/fst_data_pipeline/pipelines/tencent/bag_operation/merge_rosbag.py new file mode 100644 index 0000000..fdf123b --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/bag_operation/merge_rosbag.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import re +import shutil +import subprocess +import logging +from pathlib import Path +from concurrent.futures import ProcessPoolExecutor, as_completed + +import requests +import psycopg2 + +# ========================================================= +# 日志 +# ========================================================= +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(message)s", + handlers=[logging.FileHandler("bag_merge.log"), logging.StreamHandler()], +) +log = logging.getLogger(__name__) + +# ========================================================= +# 配置(全部有默认值) +# ========================================================= + +# API +API_URL = os.getenv("API_URL", "http://127.0.0.1:8080/api/bag/mapping") +API_TIMEOUT = int(os.getenv("API_TIMEOUT", "30")) + +# PostgreSQL +PG_DSN = os.getenv( + "PG_DSN", + "host=127.0.0.1 port=5432 dbname=test user=test password=test", +) + +# 本地临时目录 +TEMP_ROOT = Path(os.getenv("TEMP_ROOT", "/tmp/bag_merge")) + +# coscmd +COSCMD_BIN = os.getenv("COSCMD_BIN", "coscmd") +COSCMD_TIMEOUT = int(os.getenv("COSCMD_TIMEOUT", "3600")) + +# 并发数 +MAX_WORKERS = int(os.getenv("MAX_WORKERS", "4")) + +# 用于拼可访问 URL(仅用于存 DB,不影响 coscmd) +COS_ENDPOINT = os.getenv("COS_ENDPOINT", "") +COS_BUCKET = os.getenv("COS_BUCKET", "") +COS_REGION = os.getenv("COS_REGION", "") + + +# ========================================================= +# 工具函数 +# ========================================================= +def safe_name(key: str) -> str: + """ + 将 COS key 转成本地安全文件名 + """ + k = (key or "").strip().replace("\\", "/") + k = re.sub(r"/+", "/", k) + k = k.replace("..", "__") + k = k.replace("/", "__") + return k or "empty" + + +def run_cmd(cmd: list[str], *, timeout: int | None = None): + log.debug("CMD: %s", " ".join(cmd)) + subprocess.run(cmd, check=True, timeout=timeout) + + +def make_cos_url(key: str) -> str: + """ + 生成一个用于入库的 URL + """ + k = key.lstrip("/") + if COS_ENDPOINT: + return f"https://{COS_ENDPOINT.rstrip('/')}/{k}" + if COS_BUCKET and COS_REGION: + return f"https://{COS_BUCKET}.cos.{COS_REGION}.myqcloud.com/{k}" + return key + + +# ========================================================= +# 业务函数 +# ========================================================= +def fetch_mapping() -> dict: + """ + 从 API 获取 parent -> children 映射 + """ + log.info("POST %s", API_URL) + resp = requests.post( + API_URL, + json={"bag_names": ["*"]}, + headers={"Content-Type": "application/json"}, + timeout=API_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("mapping response is not a dict") + return data + + +def download_file(key: str, local: Path): + """ + coscmd download /xxx local + """ + local.parent.mkdir(parents=True, exist_ok=True) + cos_path = "/" + key.lstrip("/") + + log.info("↓ download %s -> %s", cos_path, local) + run_cmd( + [COSCMD_BIN, "download", cos_path, str(local)], + timeout=COSCMD_TIMEOUT, + ) + + +def upload_file(local: Path, key: str) -> str: + """ + coscmd upload local /xxx + """ + cos_path = "/" + key.lstrip("/") + + log.info("↑ upload %s -> %s", local, cos_path) + run_cmd( + [COSCMD_BIN, "upload", str(local), cos_path], + timeout=COSCMD_TIMEOUT, + ) + return make_cos_url(key) + + +def merge_bags(inputs: list[Path], output: Path): + """ + 调用 rosbag-merge + """ + output.parent.mkdir(parents=True, exist_ok=True) + subprocess.check_call( + ["rosbag-merge", "-o", str(output)] + [str(p) for p in inputs] + ) + + +def update_db(parent: str, cos_url: str): + """ + 更新数据库 + """ + sql = "UPDATE bag_task SET tos_path = %s WHERE parent_bag = %s" + with psycopg2.connect(PG_DSN) as conn: + with conn.cursor() as cur: + cur.execute(sql, (cos_url, parent)) + conn.commit() + log.info("[DB] %s -> %s", parent, cos_url) + + +def work_one(parent: str, children: list[str]) -> str: + """ + 单个 parent 的完整处理流程 + """ + log.info("start parent=%s children=%d", parent, len(children)) + + wd = TEMP_ROOT / safe_name(parent) + wd.mkdir(parents=True, exist_ok=True) + + try: + subs: list[Path] = [] + for c in children: + lp = wd / safe_name(c) + download_file(c, lp) + subs.append(lp) + + out = wd / safe_name(parent) + merge_bags(subs, out) + + url = upload_file(out, parent) + update_db(parent, url) + + log.info("finish parent=%s", parent) + return url + finally: + shutil.rmtree(wd, ignore_errors=True) + + +# ========================================================= +# 主入口 +# ========================================================= +def main(): + TEMP_ROOT.mkdir(parents=True, exist_ok=True) + + # 可执行文件检查(有问题会在启动阶段直接失败) + run_cmd([COSCMD_BIN, "--version"], timeout=10) + run_cmd(["rosbag-merge", "-h"], timeout=10) + + mapping = fetch_mapping() + if not mapping: + log.warning("mapping empty, exit") + return + + with ProcessPoolExecutor(max_workers=MAX_WORKERS) as pool: + futures = { + pool.submit(work_one, parent, children): parent + for parent, children in mapping.items() + } + + for fu in as_completed(futures): + parent = futures[fu] + try: + url = fu.result() + log.info("done %s -> %s", parent, url) + except Exception as e: + log.exception("failed %s: %s", parent, e) + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/README.md b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/README.md new file mode 100644 index 0000000..89d510a --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/README.md @@ -0,0 +1,7 @@ +# auto copy MMT CLA rosbag to MB dev bucket + +This is Tencent cloud function to filter the bag name and copy the bag from MMT CLA CDI storage bucket to MB dev bucket. + +2 cloud functions are used in auto_copy phase: +* filter_bag_to_kafka.py: filter the rosbag with name rule, send bag list to kafka +* copy_cdi_cos_bag.py: listen to kafka queue and perform cloud bucket data copy \ No newline at end of file diff --git a/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/__init__.py b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/copy_cdi_cos_bag.py b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/copy_cdi_cos_bag.py new file mode 100644 index 0000000..4c461f6 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/copy_cdi_cos_bag.py @@ -0,0 +1,81 @@ +# -*- coding=utf-8 + + +#####----------------------------------------------------------------##### +##### ##### +##### 使用教程/readme: ##### +##### https://cloud.tencent.com/document/product/583/30722 ##### +##### ##### +#####----------------------------------------------------------------##### + +import os +import logging +from qcloud_cos import CosConfig +from qcloud_cos import CosS3Client +from qcloud_cos import CosServiceError +import ast + +# Setting user properties, including secret_id, secret_key, region, bucket +appid = "1318950322" # Please replace with your APPID. 请替换为您的 APPID +region = "ap-shanghai-adc" # Please replace with your region. 替换为用户的region + +secret_id = os.environ.get("TENCENTCLOUD_SECRETID") +secret_key = os.environ.get("TENCENTCLOUD_SECRETKEY") +token = os.environ.get("TENCENTCLOUD_SESSIONTOKEN") + +bucket = "b-perception-e2e-1318950322" # Please replace with your COS bucket. 替换为需要写入的COS Bucket +folder = "mb_cuct_data_collection/" +# Getting configuration object. 获取配置对象 +config = CosConfig( + Region=region, Secret_id=secret_id, Secret_key=secret_key, Token=token +) +client = CosS3Client(config) +logger = logging.getLogger() + + +def copy_file(bucket_upload, key, src_key): + response1 = client.object_exists(Bucket=bucket_upload, Key=key) + if response1: + try: + destKey = folder + src_key + response2 = client.copy( + Bucket=bucket, + Key=destKey, + CopySource={"Bucket": bucket_upload, "Key": key, "Region": region}, + CopyStatus="Replaced", + PartSize=50, + MAXThread=10, + ) + return True + except CosServiceError as e: + print("e is", e) + return False + else: + print("resource file does not exist") + return False + + +def main_handler(event, context): + bucket_upload = "" + logger.info("start main handler") + for record in event["Records"]: + if "Ckafka" not in record.keys(): + print("event: no ckafka") + continue + value = record["Ckafka"]["msgBody"] + try: + data = ast.literal_eval(value) + if "cos" not in data.keys(): + print("event: no cos") + continue + bucket_upload = data["cos"]["cosBucket"]["name"] + key = record["Ckafka"]["msgKey"] + src_key = key.split("/")[-1] + print("file name is ", src_key) + except: + print("msgBody:", value) + return "message error" + if copy_file(bucket_upload, key, src_key): + print("copy success") + else: + print("copy fail") diff --git a/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/cos_to_kafka.py b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/cos_to_kafka.py new file mode 100644 index 0000000..4f220d9 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/cos_to_kafka.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +# -*- coding=utf- + +import logging +import time +import json +from kafka import KafkaProducer +from kafka.errors import KafkaError + +logger = logging.getLogger("COSToKafka") +logger.setLevel(logging.INFO) + +# file index which need copy +file_index = "PL162802" +skip_keywords = ["recording_lpnp_recording", "recording_ddloc_recording"] + + +class CosToKafka(object): + def __init__(self, host, **kwargs): + self.host = host + + self.producer = KafkaProducer( + bootstrap_servers=[self.host], + # retries = 10, + # max_in_flight_requests_per_connection = 1, + # request_timeout_ms = 30000, + # max_block_ms = 60000, + **kwargs, + ) + + def send(self, topic, event): + global count + count = 0 + + def on_send_success(record_metadata): + global count + count = count + 1 + + def on_send_error(excp): + logger.error("failed to send message", exc_info=excp) + + s_time = time.time() + + eventList = None + try: + if "Records" in event: + eventList = event["Records"] + for data in eventList: + if "cos" in data: + key_list = data["cos"]["cosObject"]["key"].split("/") + file_name = str(key_list[-1]) + if file_name.startswith(file_index): + if any(kw in file_name for kw in skip_keywords): + print("skip by blacklist:", file_name) + continue + + if file_name.endswith(".bag"): + today_str = time.strftime("%Y%m%d", time.localtime()) + with open( + f"{today_str}.txt", "a", encoding="utf-8" + ) as f: + f.write(file_name + "\n") + + _key = "/".join(key_list[3:]) + # "cos": {"cosBucket": {"appid": "1324295915", "cosRegion": "ap-shanghai", "name": "dis-source-cfdi-test", "region": "sh", "s3Region": "ap-shanghai"} + # 返回的value中, name并不是真的bucket的name, 用这个那么去访问会出问题的 + # 真实的Bucket名字是 $name = $name-$appid + _name = data["cos"]["cosBucket"]["name"] + _appid = data["cos"]["cosBucket"]["appid"] + data["cos"]["cosBucket"]["name"] = _name + "-" + _appid + + print("bucket:", _name) + print("ket:", _key) + key = _key.encode("utf-8") + value = json.dumps(data).encode("utf-8") + + self.producer.send( + topic, key=key, value=value + ).add_callback(on_send_success).add_errback(on_send_error) + else: + print("file index not match", str(key_list[-1])) + else: + print("message error") + # block until all async messages are sent + self.producer.flush() + except KafkaError as e: + return e + finally: + if self.producer is not None: + self.producer.close() + + e_time = time.time() + + return "{} messages delivered in {}s".format(count, e_time - s_time) diff --git a/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/filter_bag_to_kafka.py b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/filter_bag_to_kafka.py new file mode 100644 index 0000000..3cd6a68 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/cdi_cos_auto_copy/filter_bag_to_kafka.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# -*- coding=utf-8 + +import os +import logging +from cos_to_kafka import CosToKafka + +logger = logging.getLogger("Index") +logger.setLevel(logging.INFO) + + +def main_handler(event, context): + logger.info("start main handler") + os.environ["KAFKA_ADDRESS"] = "10.0.210.45:9092" + os.environ["KAFKA_TOPIC_NAME"] = "synchronous_copy_release_1.0" + kafka_address = os.getenv("KAFKA_ADDRESS") + kafka_topic_name = os.getenv("KAFKA_TOPIC_NAME") + + cos_to_kafka = CosToKafka( + kafka_address, + # security_protocol = "PLAINTEXT", + # sasl_mechanism = "PLAIN", + # sasl_plain_username = "ckafka-80o10xxx#lkoxx", + # sasl_plain_password = "kongllxxxx", + api_version=(1, 1, 1), + ) + + ret = cos_to_kafka.send(kafka_topic_name, event) + logger.info(ret) + return ret diff --git a/fst_data_pipeline/pipelines/tencent/decoder.py b/fst_data_pipeline/pipelines/tencent/decoder.py new file mode 100644 index 0000000..085107d --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/decoder.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import time +import argparse +import threading +import subprocess +import shutil +import logging +from datetime import datetime +import queue +import re + +from prometheus_client import start_http_server, Counter, Gauge, Summary, Histogram +from flask import Flask, request, jsonify + +# —— 常量 & 路径 —— # +BASE = os.getcwd() +INPUT_ROOT = os.path.join(BASE, "input") +OUTPUT_ROOT = os.path.join(BASE, "output") +EMPTY_DIR = os.path.join(BASE, "empty") +LOG_DIR = os.path.join(BASE, "logs") + +COS_BUCKET = "mb_raw_rosbag_decode_dirs" +DOCKER_IMAGE = ( + "artifact.swfcn.i.mercedes-benz.com/swfcn_docker/perception-3d/mmtbag_decoder:v6.6" +) +DOCKER_CMD_TEMPLATE = [ + "docker", + "run", + "--rm", + "-v", + "{in_dir}:/input", + "-v", + "{out_dir}:/output", + DOCKER_IMAGE, + "bash", + "-c", + "source /opt/ros/noetic/setup.bash && " + "/opt/perception-3d/scripts/tools/" + "mmt_bag_decoder_scripts/decoded-bag.sh /input /output 3 1", +] + +BATCH_SIZE = 50 +MAX_LOCAL = 100 +MAX_RETRIES = 3 +RETRY_DELAY_S = 2 +METRICS_PORT = 8000 + +SENTINEL = (None, None) + +# —— 日志配置 —— # +os.makedirs(LOG_DIR, exist_ok=True) +logger = logging.getLogger("pipeline") +logger.setLevel(logging.INFO) +h_info = logging.FileHandler(os.path.join(LOG_DIR, "pipeline.log"), encoding="utf-8") +h_err = logging.FileHandler(os.path.join(LOG_DIR, "error_tasks.log"), encoding="utf-8") +fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") +h_info.setFormatter(fmt) +h_err.setFormatter(fmt) +h_err.setLevel(logging.ERROR) +logger.addHandler(h_info) +logger.addHandler(h_err) + +# —— Prometheus 指标 —— # +DL_TOTAL = Counter("pipeline_download_total", "下载尝试总数") +DL_FAIL = Counter("pipeline_download_failures", "下载失败总数") +DL_RETRY = Counter("pipeline_download_retries", "下载重试总数") +PR_TOTAL = Counter("pipeline_process_total", "处理尝试总数") +PR_FAIL = Counter("pipeline_process_failures", "处理失败总数") +PR_RETRY = Counter("pipeline_process_retries", "处理重试总数") +UP_TOTAL = Counter("pipeline_upload_total", "上传尝试总数") +UP_FAIL = Counter("pipeline_upload_failures", "上传失败总数") +UP_RETRY = Counter("pipeline_upload_retries", "上传重试总数") + +DL_DUR = Summary("pipeline_download_duration_seconds", "单批下载耗时秒") +PR_DUR = Summary("pipeline_process_duration_seconds", "单批处理耗时秒") +UP_DUR = Summary("pipeline_upload_duration_seconds", "单批上传耗时秒") + +BATCH_SIZE_HIST = Histogram( + "pipeline_batch_size", + "单批任务中文件数量分布", + buckets=[1, 10, 20, 50, 100, 200, 500], +) +FILE_DL_DUR = Histogram("pipeline_file_download_duration_seconds", "单文件下载耗时分布") +BATCH_OUT_FILES = Gauge("pipeline_batch_output_file_count", "单批处理后输出文件数") + +Q_BATCH = Gauge("pipeline_queue_batches", "待下载批次数") +Q_PROC = Gauge("pipeline_queue_processing", "待处理批次数") +Q_UP = Gauge("pipeline_queue_uploading", "待上传批次数") +LOCAL_FILES = Gauge("pipeline_local_file_count", "本地 input 文件总数") + +# —— 全局队列 & inflight 计数 —— # +batch_q = queue.Queue() +proc_q = queue.Queue() +up_q = queue.Queue() + +inflight = 0 +inflight_lock = threading.Lock() + + +# —— 辅助函数 —— # +def count_local_files(): + return sum(len(files) for _, _, files in os.walk(INPUT_ROOT)) + + +def run(cmd, timeout=None): + logger.info("CMD: %s", " ".join(cmd)) + try: + p = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + start = time.time() + out_lines = [] + timed_out = False + for line in p.stdout: + out_lines.append(line) + if timeout and (time.time() - start) > timeout: + p.kill() + timed_out = True + break + code = p.wait() + return code, timed_out, "".join(out_lines) + except Exception: + logger.exception("CMD 执行异常") + return -1, False, "" + + +def with_retry(tag, fn, *args): + for i in range(1, MAX_RETRIES + 1): + code, timed_out, _ = fn(*args) + if code == 0: + return True + if timed_out: + logger.error("%s 阶段超时,不再重试", tag) + break + # 计数重试 + if tag.startswith("DL["): + DL_RETRY.inc() + if tag.startswith("PR["): + PR_RETRY.inc() + if tag.startswith("UP["): + UP_RETRY.inc() + logger.warning("%s 重试 %d/%d", tag, i, MAX_RETRIES) + time.sleep(RETRY_DELAY_S) + logger.error("%s 最终失败", tag) + return False + + +# —— 下载 —— # +@DL_DUR.time() +def do_download(batch_id, paths, batch_timeout): + if batch_id is None: + proc_q.put(SENTINEL) + return + + DL_TOTAL.inc() + start = time.time() + in_dir = os.path.join(INPUT_ROOT, batch_id) + os.makedirs(in_dir, exist_ok=True) + + # 限制本地文件数 + while count_local_files() >= MAX_LOCAL: + logger.warning("本地文件过多,暂停下载5分钟") + time.sleep(300) + + for p in paths: + if time.time() - start > batch_timeout: + logger.error("DL[%s] 下载阶段超时,跳过剩余", batch_id) + DL_FAIL.inc() + break + dst = os.path.join(in_dir, os.path.basename(p)) + f_start = time.time() + ok = with_retry( + f"DL[{batch_id}]", + lambda s, d: run( + ["coscmd", "-s", "download", s, d], + timeout=batch_timeout - (time.time() - start), + ), + p, + dst, + ) + FILE_DL_DUR.observe(time.time() - f_start) + if not ok: + DL_FAIL.inc() + + proc_q.put((batch_id, in_dir)) + + +# —— 处理 —— # +@PR_DUR.time() +def do_process(batch_id, in_dir, batch_timeout): + if batch_id is None: + up_q.put(SENTINEL) + return + + PR_TOTAL.inc() + start = time.time() + out_dir = os.path.join(OUTPUT_ROOT, batch_id) + os.makedirs(out_dir, exist_ok=True) + + cmd = [c.format(in_dir=in_dir, out_dir=out_dir) for c in DOCKER_CMD_TEMPLATE] + + def run_pr(command): + p = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + for line in p.stdout: + logger.info("[PR %s] %s", batch_id, line.rstrip()) + if time.time() - start > batch_timeout: + p.kill() + return p.wait(), True, "" + return p.wait(), False, "" + + ok = with_retry(f"PR[{batch_id}]", run_pr, cmd) + if not ok: + PR_FAIL.inc() + + # 统计输出文件 + files = [] + for r, _, fs in os.walk(out_dir): + for fn in fs: + files.append(os.path.relpath(os.path.join(r, fn), out_dir)) + BATCH_OUT_FILES.set(len(files)) + + shutil.rmtree(in_dir, ignore_errors=True) + up_q.put((batch_id, out_dir)) + + +# —— 上传 —— # +@UP_DUR.time() +def do_upload(batch_id, out_dir, batch_timeout): + global inflight + if batch_id is None: + return + + try: + UP_TOTAL.inc() + ok = with_retry( + f"UP[{batch_id}]", + lambda d: run( + ["coscmd", "-s", "upload", "-r", d, COS_BUCKET], timeout=batch_timeout + ), + out_dir, + ) + if not ok: + UP_FAIL.inc() + return + + # 删除目录结构 + for cmd in [ + ["sudo", "rsync", "-av", "--delete", f"{EMPTY_DIR}/", f"{out_dir}/"], + ["sudo", "rm", "-rf", out_dir], + ]: + run(cmd, timeout=60) + + logger.info("UP[%s] 完成", batch_id) + finally: + # 无论成功失败,任务算完成,inflight-1 + with inflight_lock: + inflight -= 1 + + +# —— Worker 模板 —— # +def worker(q, fn, timeout): + while True: + bid, data = q.get() + fn(bid, data, timeout) + q.task_done() + if bid is None: + break + + +# —— Service HTTP —— # +app = Flask(__name__) + + +@app.route("/ready", methods=["GET"]) +def api_ready(): + with inflight_lock: + busy = inflight > 0 + return jsonify(ready=not busy) + + +@app.route("/notify", methods=["POST"]) +def api_notify(): + global inflight + data = request.get_json(force=True) + if not isinstance(data, list): + return jsonify(error="Expect JSON list"), 400 + + # 兼容 bag-checker,只发 name 时补前缀 + paths = [] + for item in data: + if not isinstance(item, str): + continue + if item.startswith("mb_cuct_data_collection/"): + paths.append(item) + else: + paths.append("mb_cuct_data_collection/" + item) + + TIME_RE = re.compile(r"_(\d{8})-(\d{6})_") # 匹配 20230803-160828 + + def extract_ts(p: str) -> datetime: + m = TIME_RE.search(os.path.basename(p)) + if not m: + return datetime.min # 无法解析的放最后 + date_part, time_part = m.groups() + ts_str = f"{date_part}{time_part}" + return datetime.strptime(ts_str, "%Y%m%d%H%M%S") + + paths.sort(key=extract_ts, reverse=True) + + with inflight_lock: + inflight += 1 + + for idx in range(0, len(paths), BATCH_SIZE): + blk = paths[idx : idx + BATCH_SIZE] + BATCH_SIZE_HIST.observe(len(blk)) + bid = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + f"_{idx // BATCH_SIZE + 1}" + batch_q.put((bid, blk)) + batch_q.put(SENTINEL) + + # batch_q.put((bid, paths)) + return jsonify(status="accepted", batch_size=BATCH_SIZE), 202 + + +def start_metric_updater(): + def loop(): + while True: + Q_BATCH.set(batch_q.qsize()) + Q_PROC.set(proc_q.qsize()) + Q_UP.set(up_q.qsize()) + LOCAL_FILES.set(count_local_files()) + time.sleep(1) + + t = threading.Thread(target=loop, daemon=True) + t.start() + + +# —— 两种模式的入口 —— # +def file_mode(args): + # 读 tasks-file,分批入队,放入 sentinel,然后启动处理 + lines = [ + line.strip() for line in open(args.tasks_file, encoding="utf-8") if line.strip() + ] + for idx in range(0, len(lines), args.batch_size): + blk = lines[idx : idx + args.batch_size] + BATCH_SIZE_HIST.observe(len(blk)) + bid = ( + datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + + f"_{idx // args.batch_size + 1}" + ) + batch_q.put((bid, blk)) + batch_q.put(SENTINEL) + + start_http_server(METRICS_PORT) + logger.info("Metrics HTTP 启动,端口 %d", METRICS_PORT) + + threads = [ + threading.Thread( + target=worker, args=(batch_q, do_download, args.batch_timeout) + ), + threading.Thread(target=worker, args=(proc_q, do_process, args.batch_timeout)), + threading.Thread(target=worker, args=(up_q, do_upload, args.batch_timeout)), + ] + for t in threads: + t.start() + + # 更新指标 & 等待完成 + while batch_q.unfinished_tasks or proc_q.unfinished_tasks or up_q.unfinished_tasks: + Q_BATCH.set(batch_q.qsize()) + Q_PROC.set(proc_q.qsize()) + Q_UP.set(up_q.qsize()) + LOCAL_FILES.set(count_local_files()) + time.sleep(1) + + for t in threads: + t.join() + + logger.info("文件模式处理完成,退出。") + sys.exit(0) + + +def service_mode(args): + # 确保目录存在 + for d in (INPUT_ROOT, OUTPUT_ROOT, EMPTY_DIR, LOG_DIR): + os.makedirs(d, exist_ok=True) + + # 启动 Prometheus 和指标更新 + start_http_server(METRICS_PORT) + logger.info("Metrics HTTP 启动,端口 %d", METRICS_PORT) + start_metric_updater() + + # 启动后台 worker + threads = [ + threading.Thread( + target=worker, args=(batch_q, do_download, args.batch_timeout), daemon=True + ), + threading.Thread( + target=worker, args=(proc_q, do_process, args.batch_timeout), daemon=True + ), + threading.Thread( + target=worker, args=(up_q, do_upload, args.batch_timeout), daemon=True + ), + ] + for t in threads: + t.start() + + # 启动 Flask + logger.info("Decode Service 启动 HTTP on %s:%d", args.host, args.port) + app.run(host=args.host, port=args.port, threaded=True) + + +def main(): + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="mode", required=True) + + f = sub.add_parser("file", help="文件模式:--tasks-file") + f.add_argument("--tasks-file", required=True) + f.add_argument("--batch-size", type=int, default=BATCH_SIZE) + f.add_argument("--batch-timeout", type=int, default=3600) + + s = sub.add_parser("service", help="服务模式:启动 HTTP ready/notify") + s.add_argument("--batch-timeout", type=int, default=3600) + s.add_argument("--host", default="0.0.0.0") + s.add_argument("--port", type=int, default=5000) + + args = p.parse_args() + if args.mode == "file": + file_mode(args) + else: + service_mode(args) + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/eval_rosbag.py b/fst_data_pipeline/pipelines/tencent/eval_rosbag.py new file mode 100644 index 0000000..fa69984 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/eval_rosbag.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +python pipeline.py --check +python pipeline.py --tasks-file task.txt +""" + +import argparse +import logging +import subprocess +import sys +from pathlib import Path +from typing import List + +import requests +from tqdm import tqdm + +from fst_data_pipeline.pipelines.tencent.bag_operation.bag_scanner import cfg + +BASE = Path.cwd() +BAG, OSM, OUT, LOG = BASE / "bags", BASE / "osm", BASE / "result", BASE / "logs" +for d in (BAG, OSM, OUT, LOG): + d.mkdir(exist_ok=True) + +# ---------- 单一日志 ---------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(message)s", + handlers=[ + logging.FileHandler(LOG / "run.log"), + logging.StreamHandler(sys.stdout), + ], +) +log = logging.getLogger("pipeline") + +# ---------- 常量 ---------- +LOCAL_API_HOST = cfg.get("ROOT_DB_API", "http://10.0.240.4:5232") +IMAGE = "eval-mmt:latest" +DOCKER_CMD = [ + "sudo", + "docker", + "run", + "--rm", + "-v", + f"{BAG}:/bag_data", + "-v", + f"{OSM}:/osm_data", + "-v", + f"{OUT}:/output_folder", + IMAGE, + "bash", + "-c", + "source ~/.bashrc && python3 /root/tools/eval_tool/eval_mmt_data.py " + "--data_folder /bag_data/ --osm_folder /osm_data/ --result_folder /output_folder", +] + + +# ---------- 工具 ---------- +def runcmd(cmd: List[str]) -> None: + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + + +def fetch_baglist() -> List[str]: + url = f"{LOCAL_API_HOST}/api/fst/baglist" + resp = requests.get(url, timeout=60) + resp.raise_for_status() + return resp.json() + + +def fetch_detail(names: List[str]) -> dict: + url = f"{LOCAL_API_HOST}/api/bags/pangu/detail" + resp = requests.post(url, json=names, timeout=30) + resp.raise_for_status() + return resp.json() + + +def download_one(bag: str, info: dict) -> bool: + try: + cos_path = info["data_path"].split("/", 1)[1] + dst_dir = BAG / f"{bag}.dir" + if not dst_dir.exists(): + runcmd(["coscmd", "-s", "download", "-r", cos_path, str(dst_dir)]) + + derived = ( + requests.get(info["reserved_json"], timeout=30) + .json()["derived_dir"] + .rstrip("/") + ) + osm_cos_path = f"{derived}/LD_manual/{bag.replace('.bag', '.osm')}" + dst_osm = OSM / f"{bag.replace('.bag', '.osm')}" + if not dst_osm.exists(): + runcmd(["coscmd", "-s", "download", osm_cos_path, str(dst_osm)]) + return True + except Exception as e: + log.error("下载 %s 失败: %s", bag, e) + return False + + +def download_all(names: List[str]) -> None: + infos = fetch_detail(names) + for bag in tqdm(names, desc="Download"): + download_one(bag, infos[bag]) + log.info("全部下载完成") + + +def eval_all() -> None: + log.info("开始 Docker 评估") + runcmd(DOCKER_CMD) + log.info("评估完成") + + +# ---------- CLI ---------- +def main(): + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--tasks-file", type=Path) + group.add_argument("--check", action="store_true") + args = parser.parse_args() + + names = ( + fetch_baglist() + if args.check + else [l.strip() for l in args.tasks_file.open() if l.strip()] + ) + if not names: + log.info("列表为空") + return + + download_all(names) + eval_all() + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/ldgt_prod.py b/fst_data_pipeline/pipelines/tencent/ldgt_prod.py new file mode 100644 index 0000000..a07d532 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/ldgt_prod.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import logging +import os +import queue +import shutil +import subprocess +import sys +import threading +import time +from datetime import datetime + +from flask import Flask, request, jsonify +from prometheus_client import start_http_server, Counter, Gauge, Summary, Histogram + +# ============ 常量 & 全局队列 ============ +BASE = os.getcwd() +INPUT_ROOT = os.path.join(BASE, "input") +OUTPUT_ROOT = os.path.join(BASE, "output") +EMPTY_DIR = os.path.join(BASE, "empty") +LOG_DIR = os.path.join(BASE, "logs") + +DOWNLOAD_ENTRIES = [ + "lidar_gt_pandar128", + "camera_front_wide", + "calibration", + "raw_gnss.csv", + "raw_imu.csv", + "vehicle_wheel.csv", +] + +MAX_PL_DIRS = 10 # 本地 PL* 子目录限流阈值 +BATCH_CHUNK = 5 # 文件模式每批任务数 +DOCKER_IMAGE = "ldgt_cu11_1_devel" +MAX_RETRIES = 3 +RETRY_DELAY_S = 2 +METRICS_PORT = 8002 + +# sentinel 用于优雅停止 +# 格式:(batch_id, remote_paths, run_slam, visualize) +SENTINEL = (None, None, False, False) + +batch_q = queue.Queue() +proc_q = queue.Queue() +up_q = queue.Queue() + +# —— 日志配置 —— # +os.makedirs(LOG_DIR, exist_ok=True) +logger = logging.getLogger("pipeline") +logger.setLevel(logging.INFO) + +fh = logging.FileHandler(os.path.join(LOG_DIR, "pipeline.log"), encoding="utf-8") +fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) +logger.addHandler(fh) + +ch = logging.StreamHandler(sys.stdout) +ch.setLevel(logging.INFO) +ch.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) +logger.addHandler(ch) + +# —— 全局自增 batch_id 计数 —— # +batch_counter = 0 +counter_lock = threading.Lock() + +# ============ Prometheus 指标 ============ # +DL_TOTAL = Counter("pipeline_download_total", "下载尝试总数") +DL_FAIL = Counter("pipeline_download_failures", "下载失败总数") +DL_RETRY = Counter("pipeline_download_retries", "下载重试总数") +PR_TOTAL = Counter("pipeline_process_total", "处理尝试总数") +PR_FAIL = Counter("pipeline_process_failures", "处理失败总数") +PR_RETRY = Counter("pipeline_process_retries", "处理重试总数") +UP_TOTAL = Counter("pipeline_upload_total", "上传尝试总数") +UP_FAIL = Counter("pipeline_upload_failures", "上传失败总数") +UP_RETRY = Counter("pipeline_upload_retries", "上传重试总数") + +DL_DUR = Summary("pipeline_download_duration_seconds", "下载耗时秒") +PR_DUR = Summary("pipeline_process_duration_seconds", "处理耗时秒") +UP_DUR = Summary("pipeline_upload_duration_seconds", "上传耗时秒") + +BATCH_SIZE_HIST = Histogram( + "pipeline_batch_size", "批次大小分布", buckets=[1, 5, 10, 20, 50, 100] +) +FILE_DL_DUR = Histogram("pipeline_file_download_duration_seconds", "单文件下载耗时分布") +BATCH_OUT_FILES = Gauge("pipeline_batch_output_file_count", "输出文件数") + +Q_BATCH = Gauge("pipeline_queue_batches", "待下载批次数") +Q_PROC = Gauge("pipeline_queue_processing", "待处理批次数") +Q_UP = Gauge("pipeline_queue_uploading", "待上传批次数") +LOCAL_PL = Gauge("pipeline_local_pl_dirs", "本地 PL* 目录数") + + +# ============ 辅助函数 ============ # +def count_pl_dirs(): + # return sum(len(files) for _, _, files in os.walk(INPUT_ROOT)) + try: + return sum( + 1 + for d in os.listdir(INPUT_ROOT) + if d.startswith("PL") and os.path.isdir(os.path.join(INPUT_ROOT, d)) + ) + except FileNotFoundError: + return 0 + + +def run(cmd, timeout=None): + logger.info("RUN %s", " ".join(cmd)) + try: + p = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + start = time.time() + out = [] + while True: + line = p.stdout.readline() + if not line: + break + out.append(line) + print(line, end="", flush=True) + if timeout and time.time() - start > timeout: + p.kill() + return p.wait(), True, "".join(out) + code = p.wait() + return code, False, "".join(out) + except Exception: + logger.exception("RUN 异常") + return -1, False, "" + + +def with_retry(tag, fn, *args): + for i in range(1, MAX_RETRIES + 1): + code, timed_out, _ = fn(*args) + if code == 0: + return True + if timed_out: + logger.error("%s 超时,停止重试", tag) + break + if tag.startswith("DL["): + DL_RETRY.inc() + if tag.startswith("PR["): + PR_RETRY.inc() + if tag.startswith("UP["): + UP_RETRY.inc() + logger.warning("%s 重试 %d/%d", tag, i, MAX_RETRIES) + time.sleep(RETRY_DELAY_S) + logger.error("%s 最终失败", tag) + return False + + +# ============ 1) 下载阶段 ============ # +@DL_DUR.time() +def do_download(batch_id, remote_paths, run_slam, visualize, timeout): + if batch_id is None: + proc_q.put(SENTINEL) + return + DL_TOTAL.inc() + in_dir = os.path.join(INPUT_ROOT, batch_id) + os.makedirs(in_dir, exist_ok=True) + logger.info("DL[%s] start, %d paths", batch_id, len(remote_paths)) + + for remote in remote_paths: + bag = os.path.basename(remote.rstrip("/")) + dst = os.path.join(in_dir, bag) + os.makedirs(dst, exist_ok=True) + for ent in DOWNLOAD_ENTRIES: + src = remote.rstrip("/") + "/" + ent + if ent.endswith(".csv"): + cmd = ["coscmd", "-s", "download", src, os.path.join(dst, ent)] + else: + cmd = ["coscmd", "-s", "download", "-r", src, os.path.join(dst, ent)] + t0 = time.time() + ok = with_retry(f"DL[{batch_id}-{bag}-{ent}]", run, cmd, timeout) + FILE_DL_DUR.observe(time.time() - t0) + if not ok: + DL_FAIL.inc() + proc_q.put((batch_id, remote_paths, run_slam, visualize)) + + +# ============ 2) 处理阶段 ============ # +@PR_DUR.time() +def do_process(batch_id, remote_paths, run_slam, visualize, timeout): + if batch_id is None: + up_q.put(SENTINEL) + return + PR_TOTAL.inc() + + out_dir = os.path.join(OUTPUT_ROOT, batch_id) + in_dir = os.path.join(INPUT_ROOT, batch_id) + os.makedirs(out_dir, exist_ok=True) + logger.info("PR[%s] input in {%s},output to {%s}", batch_id, in_dir, out_dir) + # 构造 Docker 脚本 + parts = [] + if run_slam: + parts.append("cd /root/slam_scripts && ./batch_run_map.sh /input_data") + parts.append( + "cd /root/latest/perception_dnn/projects/ldgt_net && " + "python tools/custom/generate_mb_lidar_map_with_intensity.py --data_dir /input_data --output /output_data/bev_image && " + "./tools/dist_produce_mk_gt.sh /output_data/bev_image/image_data " + "~/ckpt/epoch_500.pth /output_data/pipeline_out_box 8 && " + "./tools/dist_produce_gt.sh /output_data/bev_image/image_data ~/ckpt/epoch_50.pth /output_data/pipeline_out_line 8 && " + "python tools/custom/merge_osm.py --line_osm_folder /output_data/pipeline_out_line/default_batch/osm_out --marking_osm_folder /output_data/pipeline_out_box/default_batch/osm_out --output_folder /output_data/osm_out" + ) + if visualize: + parts.append( + "python tools/vis_tool/split_lane_info.py --bag_data /input_data --pipeline_result /output_data/ &&" + " python tools/vis_tool/projection.py --bag_data /input_data --pipeline_result /output_data/ " + ) + parts.append("chmod -R 777 /output_data") + script = " && ".join(parts) + cmd = [ + "docker", + "run", + "--rm", + "--gpus", + "all", + "-v", + f"{in_dir}:/input_data", + "-v", + f"{out_dir}:/output_data", + DOCKER_IMAGE, + "bash", + "-i", + "-c", + script, + ] + ok = with_retry(f"PR[{batch_id}-{in_dir}]", run, cmd, timeout) + if not ok: + PR_FAIL.inc() + + # shutil.rmtree(in_dir, ignore_errors=True) + # shutil.rmtree(work, ignore_errors=True) + # 输出文件数 + cnt = sum( + len(files) for _, _, files in os.walk(os.path.join(OUTPUT_ROOT, batch_id)) + ) + BATCH_OUT_FILES.set(cnt) + up_q.put(( + batch_id, + remote_paths, + run_slam, + visualize, + )) + + +# ============ 3) 上传阶段 ============ # +@UP_DUR.time() +def do_upload(batch_id, remote_paths, run_slam, visualize, timeout): + if batch_id is None: + return + UP_TOTAL.inc() + logger.info("UP[%s] start", batch_id) + base = os.path.join(OUTPUT_ROOT, batch_id) + input_dir = os.path.join(INPUT_ROOT, batch_id) + + for remote in remote_paths: + bag = os.path.basename(remote.rstrip("/")) + short = bag.replace(".bag.dir", "") + target = remote.rstrip("/") + "/derived/LDGT" + # 1) osm + f1 = os.path.join(base, "osm_out", f"{short}.osm") + if os.path.isfile(f1): + cmd = ["coscmd", "-s", "upload", f1, target + f"/{short}.osm"] + with_retry(f"UP[{batch_id}-{short}-osm]", run, cmd, timeout) + # 2) split_json + d2 = os.path.join(base, "split_json", short) + if os.path.isdir(d2): + cmd = ["coscmd", "-s", "upload", "-r", d2, target + "/split_json/"] + with_retry(f"UP[{batch_id}-{short}-json]", run, cmd, timeout) + # 3) jpg + f3 = os.path.join(base, "bev_image", "image_data", f"{short}.jpg") + if os.path.isfile(f3): + cmd = ["coscmd", "-s", "upload", f3, target + "/bev_image.jpg"] + with_retry(f"UP[{batch_id}-{short}-jpg]", run, cmd, timeout) + + f4 = os.path.join(input_dir, bag, "slam_lidar_ground") + if os.path.isdir(f4): + cmd = ["coscmd", "-s", "upload", "-r", f4, target] + with_retry(f"UP[{batch_id}-{short}-slam_lidar_ground]", run, cmd, timeout) + + f5 = os.path.join(input_dir, bag, "slam_lidar_none_ground") + if os.path.isdir(f5): + cmd = ["coscmd", "-s", "upload", "-r", f5, target] + with_retry( + f"UP[{batch_id}-{short}-slam_lidar_none_ground]", run, cmd, timeout + ) + + f6 = os.path.join(input_dir, bag, "ego_motion_slam_lidar.csv") + if os.path.isfile(f6): + cmd = ["coscmd", "-s", "upload", f6, target + "/ego_motion_slam_lidar.csv"] + with_retry(f"UP[{batch_id}-{short}-csv]", run, cmd, timeout) + + shutil.rmtree(base, ignore_errors=True) + logger.info("UP[%s] done", batch_id) + + +# ============ Worker 模板 ============ # +def worker(q, fn, timeout): + while True: + batch_id, paths, run_slam, visualize = q.get() + try: + fn(batch_id, paths, run_slam, visualize, timeout) + except Exception: + logger.exception("Stage %s 失败", batch_id) + finally: + q.task_done() + if batch_id is None: + break + + +# ============ Prometheus 指标更新 ============ # +def start_metric_updater(): + def loop(): + while True: + Q_BATCH.set(batch_q.qsize()) + Q_PROC.set(proc_q.qsize()) + Q_UP.set(up_q.qsize()) + LOCAL_PL.set(count_pl_dirs()) + time.sleep(1) + + threading.Thread(target=loop, daemon=True).start() + + +# ============ Flask Service ============ # +app = Flask(__name__) + + +@app.route("/ready", methods=["GET"]) +def api_ready(): + busy = any([ + batch_q.unfinished_tasks, + proc_q.unfinished_tasks, + up_q.unfinished_tasks, + ]) + return jsonify(ready=not busy) + + +@app.route("/notify", methods=["POST"]) +def api_notify(): + global batch_counter + data = request.get_json(force=True) + if isinstance(data, list): + paths, run_slam, visualize = data, True, True + elif isinstance(data, dict): + paths = data.get("paths", []) + run_slam = data.get("run_slam", True) + visualize = data.get("visualize", True) + else: + return jsonify(error="Unsupported JSON"), 400 + + if not all(isinstance(p, str) for p in paths): + return jsonify(error="paths must be strings"), 400 + + with counter_lock: + batch_counter += 1 + bid = str(batch_counter) + + batch_q.put((bid, paths, run_slam, visualize)) + return jsonify(status="accepted", batch_id=bid), 202 + + +# ============ 主入口 ============ # +def main(): + global batch_counter + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="mode", required=True) + + f = sub.add_parser("file", help="文件模式") + f.add_argument("--tasks-file", required=True, help="每行一个 COS 路径") + f.add_argument("--batch-timeout", type=int, default=3600) + f.set_defaults(run_slam=True, visualize=True) + + s = sub.add_parser("service", help="服务模式") + s.add_argument("--batch-timeout", type=int, default=3600) + s.add_argument("--host", default="0.0.0.0") + s.add_argument("--port", type=int, default=5600) + + args = p.parse_args() + + # 启动 Prometheus HTTP & 指标更新 + start_http_server(METRICS_PORT) + start_metric_updater() + + # 启动三个阶段 worker + for q, fn in ((batch_q, do_download), (proc_q, do_process), (up_q, do_upload)): + t = threading.Thread( + target=worker, args=(q, fn, args.batch_timeout), daemon=True + ) + t.start() + + if args.mode == "file": + lines = [ + l.strip() for l in open(args.tasks_file, encoding="utf-8") if l.strip() + ] + for i in range(0, len(lines), BATCH_CHUNK): + while count_pl_dirs() >= MAX_PL_DIRS: + logger.warning( + "本地 PL* %d ≥ %d,暂停入队", count_pl_dirs(), MAX_PL_DIRS + ) + time.sleep(60) + blk = lines[i : i + BATCH_CHUNK] + with counter_lock: + batch_counter += 1 + bid = ( + datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + + f"_{i // BATCH_CHUNK + 1}" + ) + BATCH_SIZE_HIST.observe(len(blk)) + batch_q.put((bid, blk, args.run_slam, args.visualize)) + batch_q.put(SENTINEL) + batch_q.join() + proc_q.join() + up_q.join() + logger.info("File mode done.") + sys.exit(0) + else: + for d in (INPUT_ROOT, OUTPUT_ROOT, EMPTY_DIR, LOG_DIR): + os.makedirs(d, exist_ok=True) + logger.info("Starting service on %s:%d", args.host, args.port) + app.run(host=args.host, port=args.port, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/README.md b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/README.md new file mode 100644 index 0000000..7fc250a --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/README.md @@ -0,0 +1,68 @@ +# Trajectory Processing and Visualization System + +## Overview + +This system consists of two core scripts for processing GNSS trajectory data, identifying spatially overlapping trajectories, generating tile maps, and creating visualizations: + +1. **tile_generate_to_db.py** - Processes raw trajectory data, computes spatial relationships, and stores results in MongoDB +2. **tile_visualization_from_db.py** - Reads data from database and generates interactive trajectory visualizations + +## Key Features + +- **Trajectory Gridding**: Divides GNSS points into 3D grids (10m planar grid + 5m height strata) +- **Overlap Detection**: Identifies trajectories sharing more than threshold number of grids +- **Tile Mapping**: Maps trajectories to map tile system +- **Spatial Indexing**: Stores trajectories, tiles and overlap relationships in MongoDB +- **Visualization**: Generates interactive maps with trajectory paths and direction arrows + +## config.yml +-you should set the db param and tile_server_url in config.yml +-for example +mongodb: + uri: "mongodb://admin:admin@IP:port/" + db_name: "your db_name" + +tile_server: + url: "http://IP:port/styles/maptiler-basic/512/{z}/{x}/{y}.png" + + +## Usage +1. Data Processing +``` +python3 tile_generate_to_db.py [zoom_level] +``` +- Required Directory Structure: +- data_root/ +- ├── bag_1/ +- │ ├── raw_gnss.csv +- ├── bag_2/ +- │ ├── raw_gnss.csv + +2. Visualization Generation +``` +python3 tile_visualization_from_db.py +``` +- Output will be saved in: +``` +/tile_visualizations/ +``` + +# Output Files + +1. Database Collections: + +- tile_db: Tile-to-trajectory mappings + +- processed_bags: Processed trajectory data + +- overlap_db: Trajectory overlap sets + +- processed_bags: Processed bag records + +2. Local Files: + +- _overlap.json: Overlap information per trajectory + +- processed_tiles.txt: List of processed tile IDs + +- tile_visualizations/: Folder containing HTML visualizations diff --git a/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/__init__.py b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/config.yml b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/config.yml new file mode 100644 index 0000000..6bfe3e3 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/config.yml @@ -0,0 +1,7 @@ +# config.yml +mongodb: + uri: "mongodb uri" + db_name: "your db_name" + +tile_server: + url: "tiler_server_url" diff --git a/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/tile_generate_to_db.py b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/tile_generate_to_db.py new file mode 100644 index 0000000..326cacd --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/tile_generate_to_db.py @@ -0,0 +1,605 @@ +import os +import yaml +import json +import math +import pandas as pd +import utm +import hashlib +from collections import defaultdict +import logging +import sys +from typing import Dict, List, Tuple, Set, Any +from pymongo import MongoClient +from bson import ObjectId + +# 配置日志 +logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +# 全局参数 +MIN_SHARED_GRIDS = 6 # 最小共享3D网格数阈值 +MIN_GRID_FOR_ONE_TRAJECTORY = 0 # 单条轨迹最少网格数 +GRID_SIZE = 10.0 # 平面网格大小(米) +HEIGHT_STRATUM = 5.0 # 高度分层阈值(米) + + +def load_config(config_path="config.yml"): + """加载配置文件""" + try: + with open(config_path) as f: + return yaml.safe_load(f) + except Exception as e: + logger.error(f"加载配置文件失败: {e}") + raise + + +config = load_config() + + +def get_mongo_client(): + """获取MongoDB客户端""" + return MongoClient(config["mongodb"]["uri"]) + + +def get_db_collections(): + """获取数据库集合""" + client = get_mongo_client() + db = client[config["mongodb"]["db_name"]] + return { + "overlap_db": db.overlap_db, + "tile_db": db.tile_db, + "processed_bags": db.processed_bags, + } + + +def lon2tilex(lon: float, zoom: int) -> int: + """将经度转换为瓦片x坐标""" + return int((lon + 180) / 360 * (1 << zoom)) + + +def lat2tiley(lat: float, zoom: int) -> int: + """将纬度转换为瓦片y坐标""" + return int( + ( + 1 + - math.log(math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat))) + / math.pi + ) + / 2 + * (1 << zoom) + ) + + +def get_tile_neighbors(tileid: str) -> List[str]: + """获取九宫格相邻瓦片ID""" + zoom, x, y = map(int, tileid.split("/")) + neighbors = [] + + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + if dx == 0 and dy == 0: + continue # 跳过自身 + new_x, new_y = x + dx, y + dy + # 检查是否超出瓦片范围 + max_tile = 1 << zoom + if 0 <= new_x < max_tile and 0 <= new_y < max_tile: + neighbors.append(f"{zoom}/{new_x}/{new_y}") + return neighbors + + +def extract_xyz_from_gnss(csv_path: str) -> List[Tuple[float, float, float]]: + """提取包含高度信息的轨迹点""" + try: + df = pd.read_csv(csv_path) + except Exception as e: + logger.error(f"读取 {csv_path} 失败: {e}") + return [] + + # 自动适配列名 + lat_col = next((col for col in df.columns if "lat" in col.lower()), None) + lon_col = next((col for col in df.columns if "lon" in col.lower()), None) + alt_col = next( + (col for col in df.columns if "alt" in col.lower() or "height" in col.lower()), + None, + ) + + if not lat_col or not lon_col: + logger.warning(f"{csv_path} 中缺少经纬度列") + return [] + + # 如果没有高度列,使用默认高度0 + if not alt_col: + logger.warning(f"{csv_path} 中未找到高度列,将使用默认高度0") + df["altitude"] = 0 + alt_col = "altitude" + + # 过滤无效数据 + df = df.dropna(subset=[lat_col, lon_col, alt_col]) + df = df[(df[lat_col].between(-90, 90)) & (df[lon_col].between(-180, 180))] + + try: + points = [] + for lat, lon, alt in zip(df[lat_col], df[lon_col], df[alt_col]): + easting, northing, zone_num, zone_letter = utm.from_latlon(lat, lon) + points.append((easting, northing, alt, lat, lon)) + return points + except Exception as e: + logger.error(f"{csv_path} UTM 转换失败: {e}") + return [] + + +def get_3d_grid(x: float, y: float, z: float) -> Tuple[int, int, int]: + """三维网格划分""" + grid_x = int(x // GRID_SIZE) + grid_y = int(y // GRID_SIZE) + grid_z = int(z // HEIGHT_STRATUM) # 高度分层 + return (grid_x, grid_y, grid_z) + + +def get_tile_and_grids_from_path( + bag_path: str, zoom: int = 14 +) -> Tuple[Set[str], List[Tuple[float, float, float]], Set[Tuple[int, int, int]]]: + """获取瓦片ID集合和三维网格集合""" + csv_path = os.path.join(bag_path, "raw_gnss.csv") + if not os.path.exists(csv_path): + logger.warning(f"{bag_path} 不存在 raw_gnss.csv") + return None + + xyz_points = extract_xyz_from_gnss(csv_path) + if not xyz_points: + logger.warning(f"{bag_path} 无有效 GNSS 数据") + return None + + # 获取所有瓦片ID + tile_ids = set() + for x, y, z, lat, lon in xyz_points: + tile_id = f"{zoom}/{lon2tilex(lon, zoom)}/{lat2tiley(lat, zoom)}" + tile_ids.add(tile_id) + + # 获取所有3D网格 + grids = set(get_3d_grid(x, y, z) for x, y, z, _, _ in xyz_points) + + logger.info( + f"{bag_path} 共有 {len(xyz_points)} 点,映射到 {len(tile_ids)} 个瓦片和 {len(grids)} 个3D网格" + ) + return tile_ids, [(x, y, z) for x, y, z, _, _ in xyz_points], grids + + +def calculate_overlap(grids1, grids2): + """计算两个轨迹的3D网格重叠数""" + set1 = set(tuple(grid) for grid in grids1) if isinstance(grids1, list) else grids1 + set2 = set(tuple(grid) for grid in grids2) if isinstance(grids2, list) else grids2 + return len(set1 & set2) + + +def update_tile_db( + tile_db_collection, + tile_id: str, + bag_name: str, + lat: float = None, + lon: float = None, + alt: float = None, +): + """更新瓦片数据库""" + tile_data = tile_db_collection.find_one({"tileid": tile_id}) + + if tile_data: + if bag_name not in tile_data["trajectories"]: + tile_db_collection.update_one( + {"tileid": tile_id}, {"$addToSet": {"trajectories": bag_name}} + ) + if lat is not None and lon is not None and "gps" not in tile_data: + tile_db_collection.update_one( + {"tileid": tile_id}, + { + "$set": { + "gps": { + "latitude": lat, + "longitude": lon, + "altitude": alt or 0.0, + } + } + }, + ) + else: + new_tile = {"tileid": tile_id, "trajectories": [bag_name]} + if lat is not None and lon is not None: + new_tile["gps"] = { + "latitude": lat, + "longitude": lon, + "altitude": alt or 0.0, + } + tile_db_collection.insert_one(new_tile) + + +def initialize_overlap_db(collections): + """初始化或加载现有的overlap数据库""" + overlap_db_data = collections["overlap_db"].find_one({"_id": "overlap_db"}) + + if overlap_db_data: + # 转换数据结构 + overlap_db = { + "next_id": overlap_db_data.get("next_id", 1), + "sets": { + k: {"bags": set(v["bags"]), "source_bags": set(v["source_bags"])} + for k, v in overlap_db_data.get("sets", {}).items() + }, + "hash_to_id": overlap_db_data.get("hash_to_id", {}), + "bag_to_set": overlap_db_data.get("bag_to_set", {}), + "overlap_sets": { + k: set(v) for k, v in overlap_db_data.get("overlap_sets", {}).items() + }, + } + logger.info( + f"已加载现有的overlap数据库,当前最大ID: {overlap_db['next_id'] - 1}" + ) + else: + overlap_db = { + "next_id": 1, + "sets": {}, + "hash_to_id": {}, + "bag_to_set": {}, + "overlap_sets": {}, + } + collections["overlap_db"].insert_one({ + "_id": "overlap_db", + "next_id": 1, + "sets": {}, + "hash_to_id": {}, + "bag_to_set": {}, + "overlap_sets": {}, + }) + logger.info("创建新的overlap数据库") + return overlap_db + + +def update_overlap_sets( + collections, overlap_db: Dict, bag_name: str, overlap_set: Set[str] +): + """更新overlap_sets容器,不分配set_id""" + # 保存当前包的overlap集合 + if not isinstance(overlap_set, set): + overlap_set = set(overlap_set) + overlap_db["overlap_sets"][bag_name] = overlap_set + + # 更新所有相关包的overlap集合 + for other_bag in overlap_set: + if other_bag == bag_name: + continue + + if other_bag not in overlap_db["overlap_sets"]: + overlap_db["overlap_sets"][other_bag] = {other_bag} + + # 合并两个包的overlap集合 + overlap_db["overlap_sets"][other_bag].add(bag_name) + + +def assign_set_ids(collections, overlap_db: Dict): + """为所有overlap集合分配set_id""" + # 收集所有唯一的overlap集合 + unique_sets = {} + set_hashes = set() + + # 首先处理已经存在的集合,保持它们的set_id不变 + existing_sets = {} + for set_id, set_data in overlap_db["sets"].items(): + set_hash = hashlib.md5(",".join(sorted(set_data["bags"])).encode()).hexdigest() + existing_sets[set_hash] = int(set_id) + + # 找出所有唯一的overlap集合 + for bag_name, overlap_set in overlap_db["overlap_sets"].items(): + if len(overlap_set) <= 1: + continue # 跳过单独的包 + + sorted_bags = sorted(overlap_set) + set_hash = hashlib.md5(",".join(sorted_bags).encode()).hexdigest() + + if set_hash not in unique_sets: + unique_sets[set_hash] = {"bags": overlap_set, "source_bags": set()} + + # 记录来源包 + unique_sets[set_hash]["source_bags"].add(bag_name) + + # 分配set_id + new_sets = {} + for set_hash, set_data in unique_sets.items(): + if set_hash in existing_sets: + # 使用现有的set_id + set_id = existing_sets[set_hash] + new_sets[set_id] = set_data + else: + # 分配新的set_id + set_id = overlap_db["next_id"] + overlap_db["next_id"] += 1 + new_sets[set_id] = set_data + + # 更新overlap_db + overlap_db["sets"] = { + str(k): {"bags": sorted(v["bags"]), "source_bags": sorted(v["source_bags"])} + for k, v in new_sets.items() + } + + # 更新hash_to_id映射 + overlap_db["hash_to_id"] = { + hashlib.md5(",".join(sorted(v["bags"])).encode()).hexdigest(): int(k) + for k, v in new_sets.items() + } + + # 更新bag_to_set映射 + overlap_db["bag_to_set"] = {} + for set_id, set_data in new_sets.items(): + for bag in set_data["source_bags"]: + overlap_db["bag_to_set"][bag] = set_id + + +def save_overlap_db(collections, overlap_db: Dict): + """保存overlap数据库""" + # 在保存前确保所有set_id已分配 + assign_set_ids(collections, overlap_db) + + serializable_db = { + "next_id": overlap_db["next_id"], + "sets": { + str(set_id): { + "bags": sorted(data["bags"]), + "source_bags": sorted(data["source_bags"]), + } + for set_id, data in overlap_db["sets"].items() + }, + "hash_to_id": overlap_db["hash_to_id"], + "bag_to_set": overlap_db["bag_to_set"], + "overlap_sets": {k: sorted(v) for k, v in overlap_db["overlap_sets"].items()}, + } + + collections["overlap_db"].replace_one( + {"_id": "overlap_db"}, serializable_db, upsert=True + ) + logger.info(f"已保存重叠集合数据库到 MongoDB (共 {len(overlap_db['sets'])} 个集合)") + + +def update_json_files_with_set_ids(data_root: str, overlap_db: Dict, collections): + """更新所有包的JSON文件,添加set_id信息,并同步更新MongoDB""" + for bag_name, set_id in overlap_db["bag_to_set"].items(): + bag_path = os.path.join(data_root, bag_name) + overlap_json_path = os.path.join(bag_path, f"{bag_name}_overlap.json") + + # 从MongoDB获取当前包数据 + bag_data = collections["processed_bags"].find_one({"_id": bag_name}) + if not bag_data: + logger.warning(f"在MongoDB中未找到包 {bag_name} 的数据") + continue + + # 更新重叠信息 + updated_data = { + "set_id": set_id, + "trajectories": sorted(overlap_db["sets"][str(set_id)]["bags"]), + "count": len(overlap_db["sets"][str(set_id)]["bags"]), + } + + # 更新MongoDB + collections["processed_bags"].update_one( + {"_id": bag_name}, {"$set": {"overlaps_bags": updated_data}} + ) + + # 更新本地JSON文件(可选) + if os.path.exists(overlap_json_path): + with open(overlap_json_path, "r+") as f: + try: + data = json.load(f) + data["overlap_bags"] = updated_data + f.seek(0) + json.dump(data, f, indent=2) + f.truncate() + except Exception as e: + logger.error(f"更新 {overlap_json_path} 失败: {e}") + + logger.info("已更新所有包的set_id信息到MongoDB和本地JSON文件") + + +def process_bag(bag_path: str, collections, overlap_db: Dict, zoom: int = 14): + """处理单个数据包""" + bag_name = os.path.basename(bag_path) + + overlap_json_path = os.path.join(bag_path, f"{bag_name}_overlap.json") + if not os.path.exists(overlap_json_path): + with open(overlap_json_path, "w") as f: + json.dump( + { + "overlap_bags": { + "trajectories": [bag_name], + "count": 1, + "set_id": None, + } + }, + f, + ) + + # 获取瓦片ID和3D网格 + result = get_tile_and_grids_from_path(bag_path, zoom) + if result is None: + return + + tile_ids, xyz_points, grids = result + if len(grids) <= MIN_GRID_FOR_ONE_TRAJECTORY: + logger.info(f"{bag_name} (仅 {len(grids)} 个3D网格,跳过)") + return + + processed_tiles_file = os.path.join( + os.path.dirname(data_root), "processed_tiles.txt" + ) + + existing_tiles = set() + if os.path.exists(processed_tiles_file): + with open(processed_tiles_file, "r") as f: + existing_tiles = set(line.strip() for line in f if line.strip()) + + new_tiles = set(tile_ids) - existing_tiles + if new_tiles: + with open(processed_tiles_file, "a") as f: + for tile_id in new_tiles: + f.write(f"{tile_id}\n") + print("write tile_id:") + print(tile_id) + + csv_path = os.path.join(bag_path, "raw_gnss.csv") + df = pd.read_csv(csv_path) + lat_col = next((col for col in df.columns if "lat" in col.lower()), None) + lon_col = next((col for col in df.columns if "lon" in col.lower()), None) + alt_col = next( + (col for col in df.columns if "alt" in col.lower() or "height" in col.lower()), + None, + ) + + # 过滤无效数据 + if lat_col and lon_col: + df = df.dropna(subset=[lat_col, lon_col, alt_col]) + df = df[(df[lat_col].between(-90, 90)) & (df[lon_col].between(-180, 180))] + latlon_points = list( + zip(df[lat_col], df[lon_col], df[alt_col] if alt_col else [0] * len(df)) + ) + else: + latlon_points = [] + # 初始化当前包的overlap集合 + overlap_set = {bag_name} + + # 查找所有相关瓦片(包括相邻瓦片) + all_related_tiles = set(tile_ids) + for tile_id in tile_ids: + all_related_tiles.update(get_tile_neighbors(tile_id)) + + # 查找所有可能重叠的包 + candidate_bags = set() + for tile in collections["tile_db"].find({ + "tileid": {"$in": list(all_related_tiles)} + }): + candidate_bags.update(tile["trajectories"]) + + candidate_bags.discard(bag_name) + + # 检查与每个候选包的重叠情况 + for other_bag in candidate_bags: + other_data = collections["processed_bags"].find_one({"_id": other_bag}) + if not other_data: + continue + + other_grids = set(tuple(grid) for grid in other_data.get("grids", [])) + current_grids_set = set(tuple(grid) for grid in grids) + overlap_count = calculate_overlap(current_grids_set, other_grids) + + if overlap_count >= MIN_SHARED_GRIDS: + overlap_set.add(other_bag) + + # 更新overlap_sets容器 + update_overlap_sets(collections, overlap_db, bag_name, overlap_set) + + # 更新每个包的overlap JSON文件(不包含set_id) + for b in overlap_set: + if b == bag_name: + continue + + other_path = os.path.join(os.path.dirname(bag_path), b) + other_json_path = os.path.join(other_path, f"{b}_overlap.json") + + if os.path.exists(other_json_path): + with open(other_json_path, "r+") as f: + try: + other_data = json.load(f) + if bag_name not in other_data["overlap_bags"]["trajectories"]: + other_data["overlap_bags"]["trajectories"].append(bag_name) + other_data["overlap_bags"]["count"] = len( + overlap_db["overlap_sets"][b] + ) + f.seek(0) + json.dump(other_data, f, indent=2) + f.truncate() + except Exception as e: + logger.error(f"更新 {other_json_path} 失败: {e}") + + # 保存当前包的overlap信息到JSON文件(不包含set_id) + with open(overlap_json_path, "w") as f: + json.dump( + { + "overlap_bags": { + "trajectories": sorted(overlap_set), + "count": len(overlap_set), + "set_id": None, # 将在最后阶段分配 + } + }, + f, + indent=2, + ) + + # 保存到processed_bags集合 + processed_data = { + "_id": bag_name, + "tile_ids": list(tile_ids), + "grids": list(grids), + "xyz_points": xyz_points, + "latlon_points": latlon_points, + "overlaps_bags": { + "trajectories": sorted(overlap_set), + "count": len(overlap_set), + "set_id": None, # 将在最后阶段分配 + }, + } + collections["processed_bags"].replace_one( + {"_id": bag_name}, processed_data, upsert=True + ) + + # 更新瓦片数据库 + for tile_id in tile_ids: + lat, lon = None, None + for x, y, z, pt_lat, pt_lon in extract_xyz_from_gnss( + os.path.join(bag_path, "raw_gnss.csv") + ): + if f"{zoom}/{lon2tilex(pt_lon, zoom)}/{lat2tiley(pt_lat, zoom)}" == tile_id: + lat, lon = pt_lat, pt_lon + break + update_tile_db(collections["tile_db"], tile_id, bag_name, lat, lon) + + logger.info(f"{bag_name} 与 {len(overlap_set) - 1} 个包重叠") + + +def main(data_root: str, zoom: int = 14): + """主函数""" + if not os.path.isdir(data_root): + logger.error(f"数据目录不存在: {data_root}") + return + + # 初始化数据库连接 + collections = get_db_collections() + + # 初始化数据库 + overlap_db = initialize_overlap_db(collections) + + # 处理所有数据包 + bag_dirs = [ + os.path.join(data_root, d) + for d in os.listdir(data_root) + if os.path.isdir(os.path.join(data_root, d)) + ] + if not bag_dirs: + logger.error(f"目录中没有子文件夹: {data_root}") + return + + logger.info(f"开始处理 {len(bag_dirs)} 个数据包...") + for bag_path in bag_dirs: + process_bag(bag_path, collections, overlap_db, zoom) + + # 保存所有结果到MongoDB + save_overlap_db(collections, overlap_db) + + # 更新set_id信息(这会同时更新MongoDB和本地JSON) + update_json_files_with_set_ids(data_root, overlap_db, collections) + + logger.info("处理完成") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + logger.error("用法: python3 tile_generate_to_db.py [zoom_level]") + sys.exit(1) + + data_root = sys.argv[1] + zoom_level = int(sys.argv[2]) if len(sys.argv) > 2 else 14 + main(data_root, zoom_level) diff --git a/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/tile_visualization_from_db.py b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/tile_visualization_from_db.py new file mode 100644 index 0000000..f092956 --- /dev/null +++ b/fst_data_pipeline/pipelines/tencent/mta_overlap_preprocessing/tile_visualization_from_db.py @@ -0,0 +1,467 @@ +import os +import yaml +import json +import math +import pandas as pd +import plotly.graph_objects as go +import plotly.express as px +from typing import List, Dict, Set, Tuple +import utm +import sys +import logging +import numpy as np +from plotly.subplots import make_subplots +import requests +from io import BytesIO +from PIL import Image +from pymongo import MongoClient +import hashlib + +# 配置日志 +logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + + +def load_config(config_path="config.yml"): + """加载配置文件""" + try: + with open(config_path) as f: + return yaml.safe_load(f) + except Exception as e: + logger.error(f"加载配置文件失败: {e}") + raise + + +config = load_config() + + +def calculate_bearing(x1, y1, x2, y2): + """计算两点之间的朝向角(弧度)""" + dx = x2 - x1 + dy = y2 - y1 + return math.atan2(dy, dx) + + +def calculate_cluster_center(bag_grid_map, cluster): + """计算单个聚类的中心点""" + all_x = [] + all_y = [] + all_z = [] + for bag_name in cluster: + if bag_name in bag_grid_map: + xyz_points = bag_grid_map[bag_name] + all_x.extend([x for x, y, z in xyz_points]) + all_y.extend([y for x, y, z in xyz_points]) + all_z.extend([z for x, y, z in xyz_points]) + if not all_x: + return 0, 0, 0 # 默认值 + return np.mean(all_x), np.mean(all_y), np.mean(all_z) + + +def create_direction_arrows(xy_points, spacing=5): + """创建方向箭头数据""" + arrows = [] + for i in range(0, len(xy_points) - 1, spacing): + if i + 1 >= len(xy_points): + continue + x1, y1 = xy_points[i] + x2, y2 = xy_points[i + 1] + angle = calculate_bearing(x1, y1, x2, y2) + + # 箭头中间点 + mid_x = (x1 + x2) / 2 + mid_y = (y1 + y2) / 2 + + arrows.append({ + "x": mid_x, + "y": mid_y, + "angle": -angle, + "angle_deg": 90 - rad_to_deg(angle), + }) + return arrows + + +def rad_to_deg(rad): + """将弧度转换为角度""" + return rad * 180 / math.pi + + +def get_3d_grids_from_db(collections, bag_name): + """从数据库获取三维网格集合""" + bag_data = collections["processed_bags"].find_one({"_id": bag_name}) + if not bag_data: + logger.warning(f"未找到包 {bag_name} 的数据") + return None + + if "xyz_points" in bag_data: + return bag_data["xyz_points"] + elif "grids" in bag_data: + # 如果只有网格数据,没有原始点,则返回网格中心点 + grid_points = [] + for grid in bag_data["grids"]: + grid_x, grid_y, grid_z = grid + center_x = (grid_x + 0.5) * 10.0 + center_y = (grid_y + 0.5) * 10.0 + center_z = (grid_z + 0.5) * 5.0 + grid_points.append((center_x, center_y, center_z)) + return grid_points + else: + logger.warning(f"包 {bag_name} 没有轨迹点数据") + return None + + +def utm_to_latlon(easting, northing, zone_number, zone_letter): + """将UTM坐标转换为经纬度""" + try: + lat, lon = utm.to_latlon(easting, northing, zone_number, zone_letter) + return lat, lon + except Exception as e: + logger.error(f"UTM转换失败: {e}") + return None, None + + +class TileVisualizer: + def __init__(self, output_dir: str = "tile_visualizations"): + self.mongo_uri = config["mongodb"]["uri"] + self.db_name = config["mongodb"]["db_name"] + self.tile_server_url = config["tile_server"]["url"] + self.output_dir = output_dir + self.client = MongoClient(self.mongo_uri) + self.db = self.client[self.db_name] + self.collections = { + "tile_db": self.db.tile_db, + "processed_bags": self.db.processed_bags, + } + self.tile_db = self._load_tile_db() + self.color_cycle = px.colors.qualitative.Plotly + + def close(self): + if self.client is not None: + self.client.close() + self.client = None + self.db = None + + def _load_tile_db(self) -> Dict: + """从MongoDB加载tileDB数据""" + try: + tiles = list(self.collections["tile_db"].find({}, {"_id": 0})) + return {"TileDB": tiles} + except Exception as e: + logger.error(f"从MongoDB加载tileDB失败: {e}") + return {"TileDB": []} + + def _get_trajectory_points_from_db( + self, bag_name: str + ) -> List[Tuple[float, float, float]]: + """从数据库获取轨迹点(经纬度+高度)""" + bag_data = self.collections["processed_bags"].find_one({"_id": bag_name}) + if not bag_data: + logger.warning(f"未找到包 {bag_name} 的数据") + return [] + + # 尝试直接获取经纬度点 + if "latlon_points" in bag_data: + return [ + (point[0], point[1], point[2]) for point in bag_data["latlon_points"] + ] + + # 如果有UTM坐标,则转换为经纬度 + if "xyz_points" in bag_data: + # 注意:这里需要假设所有点在同一个UTM区域 + # 实际上,每个点可能有不同的区域,但为了简化,我们使用第一个点的区域 + if not bag_data["xyz_points"]: + return [] + + # 尝试获取UTM区域信息(如果存储了) + zone_num = bag_data.get("utm_zone_num", 50) # 默认值 + zone_letter = bag_data.get("utm_zone_letter", "N") # 默认值 + + latlon_points = [] + for point in bag_data["xyz_points"]: + easting, northing, alt = point + lat, lon = utm_to_latlon(easting, northing, zone_num, zone_letter) + if lat is not None and lon is not None: + latlon_points.append((lat, lon, alt)) + return latlon_points + + logger.warning(f"包 {bag_name} 没有可用的轨迹点数据") + return [] + + def _calculate_bearing( + self, lat1: float, lon1: float, lat2: float, lon2: float + ) -> float: + """计算两点之间的朝向角(度数)""" + lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) + dlon = lon2 - lon1 + x = math.sin(dlon) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos( + lat2 + ) * math.cos(dlon) + return math.degrees(math.atan2(x, y)) + + def _create_arrows( + self, lats: List[float], lons: List[float], spacing: int = 5 + ) -> List[Dict]: + """创建方向箭头数据""" + arrows = [] + for i in range(0, len(lats) - 1, spacing): + if i + 1 >= len(lats): + continue + angle = self._calculate_bearing(lats[i], lons[i], lats[i + 1], lons[i + 1]) + arrows.append({ + "lat": (lats[i] + lats[i + 1]) / 2, + "lon": (lons[i] + lons[i + 1]) / 2, + "angle": -angle, + "angle_deg": 90 - angle, + }) + return arrows + + def visualize_tile(self, tile_id: str): + """可视化单个瓦片的轨迹""" + tile_data = next( + (t for t in self.tile_db["TileDB"] if t["tileid"] == tile_id), None + ) + if not tile_data: + logger.warning(f"未找到瓦片 {tile_id} 的数据") + return + + trajectories = tile_data.get("trajectories", []) + if not trajectories: + logger.info(f"瓦片 {tile_id} 没有轨迹数据") + return + + bag_grid_map = dict() + for bag_name in trajectories: + xyz_points = get_3d_grids_from_db(self.collections, bag_name) + if xyz_points: + bag_grid_map[bag_name] = xyz_points + + fig = go.Figure() + colors = px.colors.qualitative.Plotly + all_lats, all_lons = [], [] + + # 计算聚类中心 + center_x, center_y, center_z = calculate_cluster_center( + bag_grid_map, trajectories + ) + logger.info( + f"聚类 {tile_id} 中心点: ({center_x:.1f}, {center_y:.1f}, {center_z:.1f})" + ) + + tz, tx, ty = map(int, tile_id.split("/")) + tile_images = {} + + # 下载周边瓦片 + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + ttx = tx + dx + tty = ty + dy + tile_url = self.tile_server_url.format(z=tz, x=ttx, y=tty) + try: + response = requests.get(tile_url, timeout=5) + if response.status_code == 200: + tile_images[(dx, dy)] = Image.open(BytesIO(response.content)) + else: + logger.warning( + f"无法获取瓦片 {tz}/{ttx}/{tty},使用空白图片代替" + ) + tile_images[(dx, dy)] = Image.new( + "RGB", (512, 512), (255, 255, 255) + ) + except Exception as e: + logger.error(f"下载瓦片 {tz}/{ttx}/{tty} 失败: {e}") + tile_images[(dx, dy)] = Image.new( + "RGB", (512, 512), (255, 255, 255) + ) + + # 拼接瓦片 + width = 512 * 3 + height = 512 * 3 + combined_image = Image.new("RGB", (width, height)) + + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + ttx = (dx + 1) * 512 + tty = (dy + 1) * 512 + combined_image.paste(tile_images[(dx, dy)], (ttx, tty)) + + # 添加底图 + fig.add_layout_image( + dict( + source=combined_image, + xref="x", + yref="y", + x=0, + y=height, + sizex=width, + sizey=height, + sizing="stretch", + opacity=0.8, + layer="below", + ) + ) + + def latlon_to_tile_pixel(lat, lon, tile_x, tile_y, zoom): + """将经纬度转换为瓦片像素坐标""" + n = 2**zoom + xtile = (lon + 180) / 360 * n + + ytile = ( + ( + 1 + - math.log( + math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat)) + ) + / math.pi + ) + / 2 + * n + ) + x_pixel = (xtile - tile_x - 0.386) * 512 + y_pixel = (ytile - tile_y - 0.23) * 512 + return x_pixel, y_pixel + + for i, bag_name in enumerate(trajectories): + # 从数据库获取轨迹点 + points = self._get_trajectory_points_from_db(bag_name) + if not points: + logger.warning(f"包 {bag_name} 没有轨迹点数据") + continue + + x = [] + y = [] + + for point in points: + lat, lon, _ = point + pt_x, pt_y = latlon_to_tile_pixel(lat, lon, tx, ty, tz) + + # 转换为大图的坐标 + pt_x += 512 # 中心瓦片x偏移 + pt_y += 512 # 中心瓦片y偏移 + + # 调整坐标系(原点在左上角) + pt_y = height - pt_y + + if 0 <= pt_x <= width and 0 <= pt_y <= height: + x.append(pt_x) + y.append(pt_y) + + if not x: + logger.warning(f"包 {bag_name} 没有在瓦片范围内的点") + continue + + # 轨迹线 + fig.add_trace( + go.Scatter( + x=x, + y=y, + mode="lines", + name=bag_name, + line=dict(width=4, color=colors[i % len(colors)]), + showlegend=True, + legendgroup=bag_name, + hoverinfo="text", + text=[f"{bag_name}
    点 {j + 1}/{len(x)}" for j in range(len(x))], + ) + ) + + # 方向箭头(2D投影) + if len(x) > 1: + xy_points = list(zip(x, y)) + arrows = create_direction_arrows(xy_points) + if arrows: + arrow_x = [arrow["x"] for arrow in arrows] + arrow_y = [arrow["y"] for arrow in arrows] + arrow_text = [ + f"{bag_name}
    朝向: {arrow['angle_deg']:.1f}°" + for arrow in arrows + ] + + fig.add_trace( + go.Scatter( + x=arrow_x, + y=arrow_y, + mode="markers", + name=f"{bag_name} 方向", + marker=dict( + symbol="arrow-up", + size=8, + color=colors[i % len(colors)], + angle=[arrow["angle_deg"] for arrow in arrows], + line=dict(width=1, color="black"), + ), + hoverinfo="text", + text=arrow_text, + showlegend=False, + legendgroup=bag_name, + ) + ) + + fig.update_layout( + title=f"瓦片 {tile_id} 轨迹可视化 (共{len(trajectories)}条轨迹)", + xaxis=dict( + range=[0, width], + scaleanchor="y", + title="X px", + showgrid=False, + zeroline=False, + constrain="domain", + ), + yaxis=dict( + range=[0, height], + scaleanchor="x", + title="Y px", + showgrid=False, + zeroline=False, + ), + showlegend=True, + legend=dict( + orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 + ), + height=800, + width=800, + ) + + # 保存HTML文件 + os.makedirs(self.output_dir, exist_ok=True) + safe_tile_id = tile_id.replace("/", "_") + output_path = os.path.join(self.output_dir, f"tile_{safe_tile_id}.html") + fig.write_html(output_path) + logger.info(f"已保存瓦片 {tile_id} 可视化结果到 {output_path}") + + def visualize_all_tiles(self): + """可视化所有瓦片""" + for tile in self.tile_db["TileDB"]: + self.visualize_tile(tile["tileid"]) + + def visualize_from_txt(self, txt_path: str): + try: + with open(txt_path, "r") as f: + tile_ids = set(line.strip() for line in f if line.strip()) + except FileNotFoundError: + logger.error(f"Not Found txt") + return + tiles_data = list( + self.collections["tile_db"].find({"tileid": {"$in": list(tile_ids)}}) + ) + + if not tiles_data: + logger.warning("no match tile") + return + for tile_data in tiles_data: + self.visualize_tile(tile_data["tileid"]) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + logger.error("用法: python3 tile_visualization_from_db.py ") + sys.exit(1) + + data_root = sys.argv[1] + # 创建可视化器并运行 + visualizer = TileVisualizer( + output_dir=os.path.join(data_root, "tile_visualizations") + ) + txt_path = os.path.join(data_root, "processed_tiles.txt") + visualizer.visualize_from_txt(txt_path) + visualizer.close() diff --git a/fst_data_pipeline/pipelines/volc/__init__.py b/fst_data_pipeline/pipelines/volc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/pipelines/volc/bag-copy.sh b/fst_data_pipeline/pipelines/volc/bag-copy.sh new file mode 100644 index 0000000..4c1471a --- /dev/null +++ b/fst_data_pipeline/pipelines/volc/bag-copy.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail +# 外部必须导出: +# INPUT_ROOT WORKFLOW_ID SUB_DIR OUTPUT_ROOT + +INPUT_ROOT="${INPUT_ROOT}/${WORKFLOW_ID}" +INPUT_DIR="${INPUT_ROOT}/${SUB_DIR}" # 实际指向 bagdir_splits 上一级 +OUTPUT_DIR="${OUTPUT_ROOT}" # 通常就是 /nas_perception/.../output/wfxxx + +log(){ + echo "[$(date '+%F %T')] [$$] $*" +} + +#---------------------------------------------------------- +# 统一封装:目录同步 / 文件拷贝 +#---------------------------------------------------------- +sync_dir(){ + local srcDir="$1" dstDir="$2" + if [[ -d ${srcDir} ]]; then + log " + $(basename "${srcDir}")/" + mkdir -p "${dstDir}" + rsync -a --delete "${srcDir}/" "${dstDir}/" + fi +} + +sync_file(){ + local srcFile="$1" dstDir="$2" + if [[ -f ${srcFile} ]]; then + log " + $(basename "${srcFile}")" + mkdir -p "${dstDir}" + cp -p "${srcFile}" "${dstDir}/" + fi +} + +#---------------------------------------------------------- +# 主逻辑 +#---------------------------------------------------------- +log "Script started" +log "INPUT_DIR = ${INPUT_DIR}" +log "OUTPUT_DIR = ${OUTPUT_DIR}" + +[[ -d ${INPUT_DIR} ]] || { log "ERROR: INPUT_DIR not found: ${INPUT_DIR}"; exit 1; } + +while IFS= read -r -d '' src; do + # 去掉 split_N 层级,得到纯 bag.dir 名 + rel="${src#${INPUT_DIR}/*/}" + dest="${OUTPUT_DIR}/${rel}/derived/${SUB_DIR}" + mkdir -p "${dest}" + + basename_bag=$(basename "$src") # xxx.bag.dir + pkgname="${basename_bag%.bag.dir}" # xxx + split_name=$(basename "$(dirname "$src")") # split_0 / split_1 / ... + truth_root="${INPUT_DIR}/${split_name}" + + log "==================== Processing ${pkgname} ===================" + + # 1. 老 object 重命名同步 + sync_dir "${src}/object_det_ep20" "${dest}/object_det_al" + sync_dir "${src}/object_tracking" "${dest}/object_tracking_al" + + # 2. 目录类:slam + lidar_gt + 新增 6 目录 + for item in slam_lidar_ground slam_lidar_none_ground \ + lidar_gt_pandar128_5f_front lidar_gt_pandar128_5f_rear \ + object_det_ep20_lrgt_front object_det_ep20_lrgt_rear \ + object_lrgt_filter object_postprocess; do + sync_dir "${src}/${item}" "${dest}/${item}" + done + + # 3. 文件类 + for item in bev_image_ground.png ego_motion_slam_lidar.csv; do + sync_file "${src}/${item}" "${dest}" + done + + # 4. osm & split_json(源在 OUTPUT_ROOT/SUB_DIR/split_N/) + osm_src="${truth_root}/osm_out/${pkgname}.osm" + if [[ -f ${osm_src} ]]; then + log " + ${pkgname}.osm -> input bag.dir" + cp -p "${osm_src}" "${src}/" + log " + ${pkgname}.osm -> output" + cp -p "${src}/${pkgname}.osm" "${dest}/" + fi + + split_src="${truth_root}/split_json/${pkgname}" + if [[ -d ${split_src} ]]; then + log " + split_json/ -> input bag.dir" + rsync -a --delete "${split_src}/" "${src}/split_json/" + log " + split_json/ -> output" + sync_dir "${src}/split_json" "${dest}/split_json" + fi + + # 5. 2dseg and occ + sync_dir "${src}/${SUB_DIR}" "${dest}" + +done < <(find "${INPUT_DIR}" -mindepth 2 -maxdepth 2 -type d -name '*.bag.dir' -print0) + +log "============================================================" +log "All done, success!" \ No newline at end of file diff --git a/fst_data_pipeline/pipelines/volc/bag_operation/__init__.py b/fst_data_pipeline/pipelines/volc/bag_operation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fst_data_pipeline/pipelines/volc/bag_operation/bag_scanner.py b/fst_data_pipeline/pipelines/volc/bag_operation/bag_scanner.py new file mode 100644 index 0000000..0e6a1c8 --- /dev/null +++ b/fst_data_pipeline/pipelines/volc/bag_operation/bag_scanner.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +check_bags.py + +环境变量: + BAG_DIR 必填 bag 根目录 + GT_API_URL 可选 获取 pipeline 路径的接口,默认 http://10.204.22.135:30000/api/gt/types + OUTPUT_PREFIX 可选 输出前缀(直接拼接) + 其余变量 真值控制,示例: + OBJECT_DETECTION=true + LANE_DETECTION=false + SLAM_GROUND=true + … + 仅当变量值为 true/false 时参与检查; + true → 该 path 必须存在(若是目录则不能为空) + false → 该 path 必须不存在 + 其它值或缺失 → 忽略 + +结果同时输出到 stdout 和 list.txt(每行一条完整拼接路径) +新增: + - 扫描前检查 BAG_DIR 是否为空 + - 统计:总 bag 数、规则数、通过数、失败数 + - 目录存在时额外检查“非空” +""" + +import os +import requests +import logging +import sys + +# ---------- 日志 ---------- +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s][%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +log = logging.getLogger("check_bags") + +# ---------- 1. 基础目录 ---------- +BASE = os.environ.get("BAG_DIR") +if not BASE or not os.path.isdir(BASE): + log.error("BAG_DIR not set or not a directory") + sys.exit(1) + +PREFIX = os.environ.get("OUTPUT_PREFIX", "") +GT_API_URL = os.environ.get( + "GT_API_URL", "http://10.204.22.135:30000/api/gt/types" +).rstrip() +log.info("GT_API_URL = %s", GT_API_URL) + + +# ---------- 2. 拉取 API ---------- +try: + log.info("fetching pipeline list from %s", GT_API_URL) + api = requests.get(GT_API_URL, timeout=10).json() + log.info("got %d items from API", len(api)) +except Exception as e: + log.error("API unreachable: %s", e) + sys.exit(1) + + +# ---------- 3. 收集检查规则 ---------- +checks = [] +for item in api: + if item.get("type") != "pipeline": + continue + name = item["name"] + env_val = os.environ.get(name, "").lower() + if env_val in ("true", "false"): + path = item["path"].lstrip("/") + must_exist = env_val == "true" + checks.append((path, must_exist)) + log.info("check rule: %-30s must_exist=%-5s path=%s", name, must_exist, path) + +if not checks: + log.error("No pipeline paths enabled for check") + sys.exit(1) + + +# ---------- 4. 遍历 bag + 统计 ---------- +def _empty_dir(p: str) -> bool: + """目录存在且为空返回 True""" + return os.path.isdir(p) and not bool(os.listdir(p)) + + +valid_cnt = invalid_cnt = 0 +bag_dirs = [ + d + for d in os.listdir(BASE) + if d.endswith(".bag.dir") and os.path.isdir(os.path.join(BASE, d)) +] +if not bag_dirs: + log.error("No *.bag.dir found under BAG_DIR (%s), aborting", BASE) + sys.exit(1) + +total_bag = len(bag_dirs) +log.info("start scanning %d bag(s) against %d rule(s)", total_bag, len(checks)) + +valid = [] +for bag in bag_dirs: + bag_path = os.path.join(BASE, bag) + ok = True + for rel, must_exist in checks: + full = os.path.join(bag_path, rel) + exists = os.path.exists(full) + # 关键:目录不能为空 + if must_exist and os.path.isdir(full) and _empty_dir(full): + exists = False + if exists != must_exist: + log.debug( + "bag %s failed: %s exists=%s required=%s", + bag, + rel, + exists, + must_exist, + ) + ok = False + break + if ok: + valid_cnt += 1 + valid.append(bag_path) + log.info("valid bag: %s", bag) + else: + invalid_cnt += 1 + + +# ---------- 5. 输出结果 & 统计 ---------- +out_file = "list.txt" +with open(out_file, "w") as f: + for bag_path in valid: + line = f"{PREFIX}{os.path.basename(bag_path)}" + f.write(line + "\n") + +log.info("==== summary ====") +log.info("total bags : %d", total_bag) +log.info("rules : %d", len(checks)) +log.info("passed : %d", valid_cnt) +log.info("failed : %d", invalid_cnt) +log.info("wrote %d bags to %s and stdout", len(valid), out_file) diff --git a/fst_data_pipeline/pipelines/volc/bag_operation/merge_rosbag.py b/fst_data_pipeline/pipelines/volc/bag_operation/merge_rosbag.py new file mode 100644 index 0000000..9feac88 --- /dev/null +++ b/fst_data_pipeline/pipelines/volc/bag_operation/merge_rosbag.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import os +import shutil +import subprocess +import logging +from pathlib import Path +from concurrent.futures import ProcessPoolExecutor, as_completed + +import requests +import tos +import psycopg2 +from tqdm import tqdm + +# ---------- 日志 ---------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(message)s", + handlers=[logging.FileHandler("bag_merge.log"), logging.StreamHandler()], +) +log = logging.getLogger(__name__) + +# ---------- 环境变量 ---------- +API_URL = os.getenv("API_URL") +TOS_ENDPOINT = os.getenv("TOS_ENDPOINT") +TOS_REGION = os.getenv("TOS_REGION") +TOS_BUCKET = os.getenv("TOS_BUCKET") +TOS_AK = os.getenv("TOS_ACCESS_KEY") +TOS_SK = os.getenv("TOS_SECRET_KEY") +PG_DSN = os.getenv("PG_DSN") +TEMP_ROOT = Path(os.getenv("TEMP_ROOT", "/tmp/bag_merge")) + +# ---------- TOS 客户端 ---------- +tos_client = tos.TosClientV2(TOS_AK, TOS_SK, TOS_ENDPOINT, TOS_REGION) + + +# ---------- 原子函数 ---------- +def fetch_mapping() -> dict: + log.info("POST %s", API_URL) + resp = requests.post( + API_URL, + json={"bag_names": ["*"]}, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def download_file(key: str, local: Path): + meta = tos_client.head_object(TOS_BUCKET, key) + total = int(meta.content_length) + with tqdm(total=total, unit="B", unit_scale=True, desc=f"↓ {key}") as bar: + tos_client.get_object_to_file( + TOS_BUCKET, + key, + str(local), + progress_callback=lambda c, t: bar.update(t - c), + ) + + +def upload_file(local: Path, key: str) -> str: + tos_client.put_object_from_file(TOS_BUCKET, key, str(local)) + return f"https://{TOS_BUCKET}.{TOS_ENDPOINT}/{key}" + + +def merge_bags(inputs: list[Path], output: Path): + subprocess.check_call( + ["rosbag-merge", "-o", str(output)] + [str(p) for p in inputs] + ) + + +def update_db(parent: str, tos_url: str): + sql = "UPDATE bag_task SET tos_path = %s WHERE parent_bag = %s" + with psycopg2.connect(PG_DSN) as conn: + with conn.cursor() as cur: + cur.execute(sql, (tos_url, parent)) + conn.commit() + log.info("[DB] %s tos_path ⇢ %s", parent, tos_url) + + +def work_one(parent: str, children: list[str]) -> str: + log.info("start parent=%s children=%d", parent, len(children)) + wd = TEMP_ROOT / parent + wd.mkdir(parents=True, exist_ok=True) + + subs = [wd / c for c in children] + for c, s in zip(children, subs): + download_file(c, s) + + out = wd / parent + merge_bags(subs, out) + + url = upload_file(out, parent) + update_db(parent, url) + + shutil.rmtree(wd) + log.info("finish parent=%s", parent) + return url + + +# ---------- 主入口 ---------- +def main(): + TEMP_ROOT.mkdir(parents=True, exist_ok=True) + mapping = fetch_mapping() + with ProcessPoolExecutor() as pool: + futures = {pool.submit(work_one, p, c): p for p, c in mapping.items()} + for fu in as_completed(futures): + log.info("done %s -> %s", futures[fu], fu.result()) + + +if __name__ == "__main__": + main() diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..350e15b --- /dev/null +++ b/infra/README.md @@ -0,0 +1,3 @@ +# Cloud infra + +All cloud infra scripts for fst data production line, include docker files, k8s configs and terraform scripts. \ No newline at end of file diff --git a/infra/monitor/README.md b/infra/monitor/README.md new file mode 100644 index 0000000..6487b1d --- /dev/null +++ b/infra/monitor/README.md @@ -0,0 +1,63 @@ +# Cloud infra monitor + +## 环境准备 +> Docker Engine + +>Grafana & Prometheus docker images + + +## 服务部署 + +### 创建工作目录 +```bash +mkdir -p ~/grafana/{prometheus,grafana,data} +cd grafana/ +sudo chown -R 65534:65534 ~/grafana/data +``` + +### 部署Grafana + +使用docker命令启动 +```bash +sudo docker run -d \ + --name grafana \ + -p 3000:3000 \ + -v ~/grafana/grafana:/var/lib/grafana \ + -e "GF_SECURITY_ADMIN_PASSWORD=${password}" \ + grafana/grafana:latest +``` + +### 部署Prometheus + +使用docker命令启动 +```bash +sudo docker run -d \ + --name prometheus \ + -p 9090:9090 \ + -v ~/grafana/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml \ + -v ~/grafana/data:/prometheus \ + prom/prometheus:latest \ + --config.file=/etc/prometheus/prometheus.yml \ + --storage.tsdb.path=/prometheus \ + --web.console.libraries=/etc/prometheus/console_libraries \ + --web.console.templates=/etc/prometheus/consoles \ + --web.enable-lifecycle +``` +## 验证服务 +| 服务 | 地址 | 默认账号/密码 | +| ---------- | ----------------------- |-------------------| +| Prometheus | | - | +| Grafana | | admin/${password} | + +## 配置数据源 +1. 登录 Grafana → Configuration → Data Sources → Add data source +2. 选择 Prometheus +3. 配置参数: + - URL: http://: + - Access: Server (默认) + - 点击 Save & Test + +## 导入模板 +1. 点击 + → Import → Upload JSON file +2. 选择对应的json文件 +3. 点击 Import diff --git a/infra/monitor/grafana/dashboard/4DOD/4DOD.png b/infra/monitor/grafana/dashboard/4DOD/4DOD.png new file mode 100644 index 0000000..8324f0e Binary files /dev/null and b/infra/monitor/grafana/dashboard/4DOD/4DOD.png differ diff --git a/infra/monitor/grafana/dashboard/4DOD/Auto Labeling Dashboard.json b/infra/monitor/grafana/dashboard/4DOD/Auto Labeling Dashboard.json new file mode 100644 index 0000000..cf12e9a --- /dev/null +++ b/infra/monitor/grafana/dashboard/4DOD/Auto Labeling Dashboard.json @@ -0,0 +1,745 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 4, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 7, + "panels": [], + "title": "10.0.210.6", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_retries_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_failures_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Download Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 8, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_failures_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_retries_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Process Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_failures_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_retries_total{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Upload Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 7, + "x": 0, + "y": 11 + }, + "id": 5, + "options": { + "displayLabels": [], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_duration_seconds_sum{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_duration_seconds_sum{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_duration_seconds_sum{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stage Duration", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 11, + "x": 7, + "y": 11 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_batches{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_processing{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_uploading{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Queue Size", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 11 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_local_subdir_count{job=\"auto-labeling-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Local Rosbag Sum", + "type": "stat" + } + ], + "preload": false, + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "2025-07-15T22:49:59.520Z", + "to": "2025-07-18T21:42:20.460Z" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Auto Labeling Dashboard", + "uid": "aaa", + "version": 9 +} \ No newline at end of file diff --git a/infra/monitor/grafana/dashboard/Decoder/Rosbag Decoder Dashboard.json b/infra/monitor/grafana/dashboard/Decoder/Rosbag Decoder Dashboard.json new file mode 100644 index 0000000..40b7df1 --- /dev/null +++ b/infra/monitor/grafana/dashboard/Decoder/Rosbag Decoder Dashboard.json @@ -0,0 +1,740 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 7, + "panels": [], + "title": "Main", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_retries_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_failures_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Download Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 8, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_failures_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_retries_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Process Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_failures_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_retries_total{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Upload Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 7, + "x": 0, + "y": 11 + }, + "id": 5, + "options": { + "displayLabels": [], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "pipeline_download_duration_seconds_sum{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_duration_seconds_sum{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_duration_seconds_sum{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Stage Duration", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 11, + "x": 7, + "y": 11 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_batches{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_processing{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_uploading{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Queue Size", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 11 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_local_file_count{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Local Rosbag Sum", + "type": "stat" + } + ], + "preload": false, + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-12h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Rosbag Decoder Dashboard", + "uid": "467cc95c-9817-429b-9b61-b951a10b4df2", + "version": 40 +} \ No newline at end of file diff --git a/infra/monitor/grafana/dashboard/Decoder/decoder.png b/infra/monitor/grafana/dashboard/Decoder/decoder.png new file mode 100644 index 0000000..2d1c04b Binary files /dev/null and b/infra/monitor/grafana/dashboard/Decoder/decoder.png differ diff --git a/infra/monitor/grafana/dashboard/LDGT/LDGT Dashboard.json b/infra/monitor/grafana/dashboard/LDGT/LDGT Dashboard.json new file mode 100644 index 0000000..196ec4d --- /dev/null +++ b/infra/monitor/grafana/dashboard/LDGT/LDGT Dashboard.json @@ -0,0 +1,745 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 5, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 7, + "panels": [], + "title": "10.0.210.6", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_retries_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_failures_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Download Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 8, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_failures_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_retries_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Process Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_failures_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_retries_total{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Upload Stage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 7, + "x": 0, + "y": 11 + }, + "id": 5, + "options": { + "displayLabels": [], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_process_duration_seconds_sum{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_upload_duration_seconds_sum{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_download_duration_seconds_sum{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stage Duration", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 11, + "x": 7, + "y": 11 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_batches{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_processing{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_queue_uploading{job=\"ldgt-metrics\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Queue Size", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cen4i7q79xc00d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 11 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "pipeline_local_file_count{job=\"rosbag_decoder-metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Local Rosbag Sum", + "type": "stat" + } + ], + "preload": false, + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "2025-07-19T05:18:48.389Z", + "to": "2025-07-22T23:20:43.519Z" + }, + "timepicker": {}, + "timezone": "browser", + "title": "LDGT Dashboard", + "uid": "112", + "version": 8 +} \ No newline at end of file diff --git a/infra/monitor/grafana/dashboard/LDGT/LDGT.png b/infra/monitor/grafana/dashboard/LDGT/LDGT.png new file mode 100644 index 0000000..67d9ebb Binary files /dev/null and b/infra/monitor/grafana/dashboard/LDGT/LDGT.png differ diff --git a/infra/monitor/grafana/dashboard/Warehouse/Rosbag Warehouse Dashboard.json b/infra/monitor/grafana/dashboard/Warehouse/Rosbag Warehouse Dashboard.json new file mode 100644 index 0000000..2841deb --- /dev/null +++ b/infra/monitor/grafana/dashboard/Warehouse/Rosbag Warehouse Dashboard.json @@ -0,0 +1,2363 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 2, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 25, + "panels": [], + "title": "Rosbag Life Cycle", + "type": "row" + }, + { + "datasource": { + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": true, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 68, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "none" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "duration" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "bag_count" + }, + "properties": [] + } + ] + }, + "gridPos": { + "h": 8, + "w": 10, + "x": 0, + "y": 1 + }, + "id": 6, + "options": { + "barRadius": 0, + "barWidth": 1, + "colorByField": "bag_count", + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "vertical", + "showValue": "always", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xField": "duration", + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\r\n COUNT(*) AS bag_count,\r\n EXTRACT(DAY FROM (decode_time - collect_time)) AS duration\r\nFROM\r\n bag_lifecycle \r\nWHERE decode_time IS NOT NULL AND decode_time IS NOT NULL \r\nGROUP BY duration\r\nORDER BY duration\r\nLIMIT 15", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Duration from collect to decoded in month", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "bag_name" + }, + "properties": [ + { + "id": "custom.width", + "value": 436 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 10, + "x": 10, + "y": 1 + }, + "id": 26, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "rawSql": "SELECT bag_name, clone2dev_time FROM bag_lifecycle ORDER BY collect_time DESC LIMIT 10 ", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [ + { + "name": "bag_name", + "type": "functionParameter" + } + ], + "type": "function" + }, + { + "parameters": [ + { + "name": "clone2dev_time", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 10, + "orderBy": { + "property": { + "name": "collect_time", + "type": "string" + }, + "type": "property" + }, + "orderByDirection": "DESC" + }, + "table": "bag_lifecycle" + } + ], + "title": "Latest collected 10 rosbags ", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 24, + "panels": [], + "title": "MB DC Rosbag", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "rawSql": "SELECT COUNT(id) FROM bag_list WHERE project_id = 1 LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "name": "COUNT", + "parameters": [ + { + "name": "id", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "baaa98a9-89ab-4cde-b012-31978b2c0a4b", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "9aa8ba88-0123-4456-b89a-b1978788407b", + "type": "group" + }, + "whereString": "project_id = 1" + }, + "table": "bag_list" + } + ], + "title": "DC Rosbag in total", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [], + "unit": "none" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "count" + }, + "properties": [] + } + ] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 8, + "y": 10 + }, + "id": 2, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(is_decoded) AS \"undecoded\" FROM bag_list WHERE (is_decoded = false AND project_id = 1) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"undecoded\"", + "name": "COUNT", + "parameters": [ + { + "name": "is_decoded", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "is_decoded", + "fieldSrc": "field", + "operator": "equal", + "value": [ + false + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "aba889aa-89ab-4cde-b012-31978b37cc6a", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(is_decoded = false AND project_id = 1)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(is_decoded) AS \"decoded\" FROM bag_list WHERE (is_decoded = true AND project_id = 1) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"decoded\"", + "name": "COUNT", + "parameters": [ + { + "name": "is_decoded", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "is_decoded", + "fieldSrc": "field", + "operator": "equal", + "value": [ + true + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "a8898ba9-4567-489a-bcde-f1978b37e802", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(is_decoded = true AND project_id = 1)" + }, + "table": "bag_list" + } + ], + "title": "Decoded in total", + "type": "piechart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [], + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 12, + "y": 10 + }, + "id": 8, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(od_annotated) AS \"not annotated\" FROM bag_list WHERE (od_annotated = 0 AND project_id = 1) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"not annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "od_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "od_annotated", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "ba8aaa88-89ab-4cde-b012-31978b3724e7", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(od_annotated = 0 AND project_id = 1)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(od_annotated) AS \"OD annotated\" FROM bag_list WHERE (od_annotated <> 0 AND project_id = 1) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"OD annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "od_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "od_annotated", + "fieldSrc": "field", + "operator": "not_equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "9b9988a9-4567-489a-bcde-f1978b37589d", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(od_annotated <> 0 AND project_id = 1)" + }, + "table": "bag_list" + } + ], + "title": "OD annotated in total", + "type": "piechart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [], + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 16, + "y": 10 + }, + "id": 19, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(ld_annotated) AS \"not annotated\" FROM bag_list WHERE (ld_annotated = 0 AND project_id = 1) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"not annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "ld_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "ld_annotated", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "ba8aaa88-89ab-4cde-b012-31978b3724e7", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(ld_annotated = 0 AND project_id = 1)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(ld_annotated) AS \"LD annotated\" FROM bag_list WHERE (ld_annotated <> 0 AND project_id = 1) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"LD annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "ld_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "ld_annotated", + "fieldSrc": "field", + "operator": "not_equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "9b9988a9-4567-489a-bcde-f1978b37589d", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(ld_annotated <> 0 AND project_id = 1)" + }, + "table": "bag_list" + } + ], + "title": "LD annotated in total", + "type": "piechart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 20, + "y": 10 + }, + "id": 4, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "/.*/", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(fst_indexed) AS \"not indexed\" FROM bag_list WHERE (fst_indexed = false AND project_id = 1) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"not indexed\"", + "name": "COUNT", + "parameters": [ + { + "name": "fst_indexed", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "fst_indexed", + "fieldSrc": "field", + "operator": "equal", + "value": [ + false + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "b8b8bbbb-4567-489a-bcde-f197f301d62a", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(fst_indexed = false AND project_id = 1)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(fst_indexed) AS \"indexed\" FROM bag_list WHERE (fst_indexed = true AND project_id = 1) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"indexed\"", + "name": "COUNT", + "parameters": [ + { + "name": "fst_indexed", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "fst_indexed", + "fieldSrc": "field", + "operator": "equal", + "value": [ + true + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "99ab8a98-0123-4456-b89a-b197f301f518", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 1 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(fst_indexed = true AND project_id = 1)" + }, + "table": "bag_list" + } + ], + "title": "FST indexed in total", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 23, + "panels": [], + "title": "Momenta Rosbag", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "rawSql": "SELECT COUNT(id) FROM bag_list WHERE project_id = 2 LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "name": "COUNT", + "parameters": [ + { + "name": "id", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "baaa98a9-89ab-4cde-b012-31978b2c0a4b", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "9aa8ba88-0123-4456-b89a-b1978788407b", + "type": "group" + }, + "whereString": "project_id = 2" + }, + "table": "bag_list" + } + ], + "title": "Momenta Rosbag in total", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [], + "unit": "none" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "count" + }, + "properties": [] + } + ] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 8, + "y": 18 + }, + "id": 15, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(is_decoded) AS \"undecoded\" FROM bag_list WHERE (is_decoded = false AND project_id = 2) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"undecoded\"", + "name": "COUNT", + "parameters": [ + { + "name": "is_decoded", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "is_decoded", + "fieldSrc": "field", + "operator": "equal", + "value": [ + false + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "aba889aa-89ab-4cde-b012-31978b37cc6a", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(is_decoded = false AND project_id = 2)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(is_decoded) AS \"decoded\" FROM bag_list WHERE (is_decoded = true AND project_id = 2) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"decoded\"", + "name": "COUNT", + "parameters": [ + { + "name": "is_decoded", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "is_decoded", + "fieldSrc": "field", + "operator": "equal", + "value": [ + true + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "a8898ba9-4567-489a-bcde-f1978b37e802", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(is_decoded = true AND project_id = 2)" + }, + "table": "bag_list" + } + ], + "title": "Decoded in total", + "type": "piechart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [], + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 12, + "y": 18 + }, + "id": 22, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(od_annotated) AS \"not annotated\" FROM bag_list WHERE (od_annotated = 0 AND project_id = 2) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"not annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "od_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "od_annotated", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "ba8aaa88-89ab-4cde-b012-31978b3724e7", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(od_annotated = 0 AND project_id = 2)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(od_annotated) AS \"OD annotated\" FROM bag_list WHERE (od_annotated <> 0 AND project_id = 2) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"OD annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "od_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "od_annotated", + "fieldSrc": "field", + "operator": "not_equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "9b9988a9-4567-489a-bcde-f1978b37589d", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(od_annotated <> 0 AND project_id = 2)" + }, + "table": "bag_list" + } + ], + "title": "OD annotated in total", + "type": "piechart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [], + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 16, + "y": 18 + }, + "id": 21, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(ld_annotated) AS \"not annotated\" FROM bag_list WHERE (ld_annotated = 0 AND project_id = 2) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"not annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "ld_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "ld_annotated", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "ba8aaa88-89ab-4cde-b012-31978b3724e7", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(ld_annotated = 0 AND project_id = 2)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(ld_annotated) AS \"LD annotated\" FROM bag_list WHERE (ld_annotated <> 0 AND project_id = 2) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"LD annotated\"", + "name": "COUNT", + "parameters": [ + { + "name": "ld_annotated", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "ld_annotated", + "fieldSrc": "field", + "operator": "not_equal", + "value": [ + 0 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + }, + { + "id": "9b9988a9-4567-489a-bcde-f1978b37589d", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(ld_annotated <> 0 AND project_id = 2)" + }, + "table": "bag_list" + } + ], + "title": "LD annotated in total", + "type": "piechart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 20, + "y": 18 + }, + "id": 14, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "/.*/", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(fst_indexed) AS \"not indexed\" FROM bag_list WHERE (fst_indexed = false AND project_id = 2) LIMIT 50 ", + "refId": "A", + "sql": { + "columns": [ + { + "alias": "\"not indexed\"", + "name": "COUNT", + "parameters": [ + { + "name": "fst_indexed", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "b8bbb8ba-cdef-4012-b456-7197682d8e30", + "properties": { + "field": "fst_indexed", + "fieldSrc": "field", + "operator": "equal", + "value": [ + false + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "b8b8bbbb-4567-489a-bcde-f197f301d62a", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(fst_indexed = false AND project_id = 2)" + }, + "table": "bag_list" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "ben8equ5vpj40c" + }, + "editorMode": "builder", + "format": "table", + "hide": false, + "rawSql": "SELECT COUNT(fst_indexed) AS \"indexed\" FROM bag_list WHERE (fst_indexed = true AND project_id = 2) LIMIT 50 ", + "refId": "B", + "sql": { + "columns": [ + { + "alias": "\"indexed\"", + "name": "COUNT", + "parameters": [ + { + "name": "fst_indexed", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50, + "whereJsonTree": { + "children1": [ + { + "id": "8a8a999b-0123-4456-b89a-b197682c9d72", + "properties": { + "field": "fst_indexed", + "fieldSrc": "field", + "operator": "equal", + "value": [ + true + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + }, + "type": "rule" + }, + { + "id": "99ab8a98-0123-4456-b89a-b197f301f518", + "properties": { + "field": "project_id", + "fieldSrc": "field", + "operator": "equal", + "value": [ + 2 + ], + "valueError": [ + null + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + }, + "type": "rule" + } + ], + "id": "a8a88889-0123-4456-b89a-b19768289ad8", + "type": "group" + }, + "whereString": "(fst_indexed = true AND project_id = 2)" + }, + "table": "bag_list" + } + ], + "title": "FST_indexed in total", + "type": "piechart" + } + ], + "preload": false, + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "2025-07-27T22:14:47.230Z", + "to": "2025-07-28T10:14:47.230Z" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Rosbag Warehouse Dashboard", + "uid": "64a3ad61-b82c-44e0-bfbc-66051b2dbc1a", + "version": 88 +} \ No newline at end of file diff --git a/infra/monitor/grafana/dashboard/Warehouse/warehouse.PNG b/infra/monitor/grafana/dashboard/Warehouse/warehouse.PNG new file mode 100644 index 0000000..e19cd77 Binary files /dev/null and b/infra/monitor/grafana/dashboard/Warehouse/warehouse.PNG differ diff --git a/infra/monitor/prometheus/prometheus.yml b/infra/monitor/prometheus/prometheus.yml new file mode 100644 index 0000000..8fc87b3 --- /dev/null +++ b/infra/monitor/prometheus/prometheus.yml @@ -0,0 +1,20 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'rosbag_decoder-metrics' + metrics_path: /metrics + static_configs: + - targets: ['10.0.210.6:8000'] + + - job_name: 'auto-labeling-metrics' + scrape_interval: 10s + metrics_path: /metrics + static_configs: + - targets: ['10.0.210.6:8001'] + + - job_name: 'ldgt-metrics' + scrape_interval: 10s + metrics_path: /metrics + static_configs: + - targets: ['10.0.210.8:8002'] diff --git a/infra/tencent/docker_ci/Dockerfile.python-format b/infra/tencent/docker_ci/Dockerfile.python-format new file mode 100644 index 0000000..7f6d1a7 --- /dev/null +++ b/infra/tencent/docker_ci/Dockerfile.python-format @@ -0,0 +1,6 @@ +FROM python:3.12-alpine + +ARG RUFF_VERSION="0.9.10" + +RUN apk add sudo bash git parallel +RUN pip install ruff==$RUFF_VERSION diff --git a/infra/tencent/docker_ci/Dockerfile.root_db b/infra/tencent/docker_ci/Dockerfile.root_db new file mode 100644 index 0000000..aababda --- /dev/null +++ b/infra/tencent/docker_ci/Dockerfile.root_db @@ -0,0 +1,25 @@ +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim-bookworm AS builder +#FROM artifacts.swf.i.mercedes-benz.com/panguprod-docker/fst_data_pipeline/python:3.12-slim-bookworm AS builder +#COPY --from=artifacts.swf.i.mercedes-benz.com/panguprod-docker/fst_data_pipeline/astral-sh/uv:0.7.21 /uv /uvx /bin/ +COPY --from=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +WORKDIR /app + +COPY fst_data_pipeline/apps/root_db_api/pyproject.toml fst_data_pipeline/apps/root_db_api/uv.lock ./ + +RUN uv venv .venv && \ + uv pip install -r pyproject.toml + +COPY . . +RUN uv pip install --no-deps . + +# ---------- 运行时 ---------- +#FROM artifacts.swf.i.mercedes-benz.com/panguprod-docker/fst_data_pipeline/python:3.12-slim-bookworm +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim-bookworm +RUN groupadd -r app && useradd -r -g app app +COPY --from=builder --chown=app:app /app/.venv /app/.venv +COPY --from=builder --chown=app:app /app /app +ENV VIRTUAL_ENV=/app/.venv PATH="/app/.venv/bin:$PATH" +WORKDIR /app +USER app +EXPOSE 5232 +CMD ["gunicorn", "fst_data_pipeline.apps.root_db_api.src.app:app", "-b", "0.0.0.0:5232", "-w", "32"] \ No newline at end of file diff --git a/infra/tencent/docker_ci/docker-compose.yaml b/infra/tencent/docker_ci/docker-compose.yaml new file mode 100644 index 0000000..06603df --- /dev/null +++ b/infra/tencent/docker_ci/docker-compose.yaml @@ -0,0 +1,11 @@ +version: '3' + +services: + python-format: + image: fst_data_pipeline:python-format-v1 + build: + dockerfile: Dockerfile.python-format + context: . + volumes: + - ../../..:/fst_data_pipeline + working_dir: /fst_data_pipeline diff --git a/infra/tencent/docker_ci/entrypoint_python_format.sh b/infra/tencent/docker_ci/entrypoint_python_format.sh new file mode 100644 index 0000000..b4f3eb9 --- /dev/null +++ b/infra/tencent/docker_ci/entrypoint_python_format.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env sh +set -euo pipefail + +current_dir=$(dirname "$0") +cd "${current_dir}/../../.." || exit + +fetch=0 +check=0 +no_lint=0 +fix=0 +unsafe_fixes=0 +format_all=0 + +while [ $# -gt 0 ]; do + case "$1" in + --fetch) fetch=1 ;; + --check) check=1 ;; + --no-lint) no_lint=1 ;; + --fix) fix=1 ;; + --unsafe-fixes) unsafe_fixes=1 ;; + --format-all) format_all=1 ;; + *) + echo "ignored unexpected arguments $1" + ;; + esac + shift +done + +if [ "$fetch" -eq 1 ]; then + git fetch origin +fi + +echo -e "\033[33mchecking python code style, wait a moment...\033[0m" + +if [ "$format_all" -eq 1 ]; then + py_source_code_files=$(find . -type f -name "*.py" ! -path "*/.venv/*" ! -path "*/venv/*") +else + py_source_code_files=$(git diff --name-only --diff-filter=ACMRTUXB origin/main | grep -E '\.py$' || true) +fi + +if [ -n "${py_source_code_files}" ]; then + if [ "$check" -eq 1 ]; then + echo -e "checking python files: \n${py_source_code_files}" + echo -e "-----------------------------------\033[32m ruff checking \033[0m-----------------------------------" + ruff format --respect-gitignore --check --diff --preview ${py_source_code_files} + if [ "$no_lint" -eq 0 ]; then + ruff check --respect-gitignore ${py_source_code_files} + else + echo "no lint!!!" + fi + else + echo -e "fixing python files: \n${py_source_code_files}" + if [ "$fix" -eq 1 ]; then + echo -e "-----------------------------------\033[32m ruff fixing \033[0m-----------------------------------" + ruff check --respect-gitignore --fix ${py_source_code_files} + elif [ "${unsafe_fixes}" -eq 1 ]; then + echo -e "-----------------------------------\033[32m ruff unsafe-fixing \033[0m-----------------------------------" + ruff check --respect-gitignore --fix --unsafe-fixes ${py_source_code_files} + fi + echo -e "-----------------------------------\033[32m ruff formatting \033[0m-----------------------------------" + ruff format --respect-gitignore ${py_source_code_files} + echo -e "\033[32mall python code matches the coding style\033[0m" + fi +else + echo "no python files to process" +fi diff --git a/infra/tencent/merge/Dockerfile b/infra/tencent/merge/Dockerfile new file mode 100644 index 0000000..cf5fd6d --- /dev/null +++ b/infra/tencent/merge/Dockerfile @@ -0,0 +1,34 @@ +FROM artifacts.swf.i.mercedes-benz.com/panguprod-docker/fst_data_pipeline/ros:noetic-desktop-full-focal + +ENV DEBIAN_FRONTEND=noninteractive +SHELL ["/bin/bash", "-c"] + +# ---------- OS tools ---------- +RUN apt-get update && apt-get install -y \ + bash \ + curl \ + wget \ + ca-certificates \ + python3 \ + python3-pip \ + tzdata \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# ---------- Python deps ---------- +RUN pip3 install --no-cache-dir \ + psycopg2-binary \ + requests \ + tqdm \ + coscmd + +# ---------- App ---------- +WORKDIR /app +COPY runner.py /app/runner.py +COPY merge_ros1.sh /app/merge_ros1.sh +RUN chmod +x /app/merge_ros1.sh + +# ROS env +RUN echo "source /opt/ros/noetic/setup.bash" >> /etc/profile + +ENTRYPOINT ["python3", "/app/runner.py"] diff --git a/infra/tencent/merge/merge_ros1.sh b/infra/tencent/merge/merge_ros1.sh new file mode 100644 index 0000000..69210f9 --- /dev/null +++ b/infra/tencent/merge/merge_ros1.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "Usage: merge_ros1.sh OUT.bag IN1.bag [IN2.bag ...]" + exit 1 +fi + +OUT_BAG="$1" +shift +IN_BAGS=("$@") + +source /opt/ros/noetic/setup.bash + +echo "[ROS] start roscore" +roscore >/tmp/roscore.log 2>&1 & +ROSCORE_PID=$! +sleep 3 + +echo "[ROS] start record -> $OUT_BAG" +rosbag record -a -x "/rosout.*" -O "$OUT_BAG" >/tmp/rosbag_record.log 2>&1 & +REC_PID=$! +sleep 2 + +for b in "${IN_BAGS[@]}"; do + echo "[ROS] play $b" + rosbag play "$b" >/tmp/rosbag_play.log 2>&1 +done + +echo "[ROS] stop record" +kill -INT "$REC_PID" || true +wait "$REC_PID" || true + +echo "[ROS] stop roscore" +kill -INT "$ROSCORE_PID" || true + +echo "[ROS] merge done -> $OUT_BAG" diff --git a/infra/tencent/merge/runner.py b/infra/tencent/merge/runner.py new file mode 100644 index 0000000..da530b2 --- /dev/null +++ b/infra/tencent/merge/runner.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import re +import time +import shutil +import logging +import subprocess +from pathlib import Path +from concurrent.futures import ProcessPoolExecutor, as_completed + +import psycopg2 + +# ========================================================= +# Logging +# ========================================================= +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s | %(levelname)s | %(message)s", + handlers=[logging.StreamHandler()], +) +log = logging.getLogger("pangu_joined_runner") + +# ========================================================= +# Config (env only) +# ========================================================= +PG_DSN = os.getenv( + "PG_DSN", + "host=127.0.0.1 port=5432 dbname=test user=test password=test", +) + +TEMP_ROOT = Path(os.getenv("TEMP_ROOT", "/tmp/pangu_join")) +MAX_WORKERS = int(os.getenv("MAX_WORKERS", "2")) + +COSCMD_BIN = os.getenv("COSCMD_BIN", "coscmd") +COSCMD_TIMEOUT = int(os.getenv("COSCMD_TIMEOUT", "3600")) + +COS_ENDPOINT = os.getenv("COS_ENDPOINT", "") +COS_BUCKET = os.getenv("COS_BUCKET", "") +COS_REGION = os.getenv("COS_REGION", "") + +MERGED_PREFIX = os.getenv("MERGED_PREFIX", "joined") +AUTO_FIX = os.getenv("AUTO_FIX", "0") == "1" + +# 固定表结构(你已确认) +BAG_TABLE = "bag_list" +JOINED_BAGS_TABLE = "joined_bags" +JOINED_PANGU_TABLE = "joined_pangu" + + +# ========================================================= +# Helpers +# ========================================================= +def safe_name(s: str) -> str: + s = (s or "").strip().replace("\\", "/") + s = re.sub(r"/+", "/", s) + s = s.replace("..", "__") + return s.replace("/", "__") or "empty" + + +def run_cmd(cmd: list[str], *, timeout: int | None = None): + log.info("CMD: %s", " ".join(cmd)) + subprocess.run(cmd, check=True, timeout=timeout) + + +def make_cos_url(key: str) -> str: + k = key.lstrip("/") + if COS_ENDPOINT: + return f"https://{COS_ENDPOINT.rstrip('/')}/{k}" + if COS_BUCKET and COS_REGION: + return f"https://{COS_BUCKET}.cos.{COS_REGION}.myqcloud.com/{k}" + return key + + +def cos_download(key: str, local: Path): + local.parent.mkdir(parents=True, exist_ok=True) + cos_path = "/" + key.lstrip("/") + run_cmd([COSCMD_BIN, "download", cos_path, str(local)], timeout=COSCMD_TIMEOUT) + + +def cos_upload(local: Path, key: str) -> str: + cos_path = "/" + key.lstrip("/") + run_cmd([COSCMD_BIN, "upload", str(local), cos_path], timeout=COSCMD_TIMEOUT) + return make_cos_url(key) + + +# ========================================================= +# DB helpers +# ========================================================= +def db_fetchall(sql: str, args=()): + with psycopg2.connect(PG_DSN) as conn: + with conn.cursor() as cur: + cur.execute(sql, args) + return cur.fetchall() + + +def db_execute(sql: str, args=()): + with psycopg2.connect(PG_DSN) as conn: + with conn.cursor() as cur: + cur.execute(sql, args) + conn.commit() + + +def fetch_parent_ids() -> list[int]: + rows = db_fetchall( + f"SELECT DISTINCT parent_id FROM {JOINED_BAGS_TABLE} ORDER BY parent_id" + ) + return [int(r[0]) for r in rows] + + +def fetch_children_ids(parent_id: int) -> list[int]: + rows = db_fetchall( + f"SELECT child_id FROM {JOINED_BAGS_TABLE} WHERE parent_id=%s", + (parent_id,), + ) + return [int(r[0]) for r in rows] + + +def fetch_bag_meta(bag_id: int) -> tuple[str, str]: + rows = db_fetchall( + f"SELECT name, data_path FROM {BAG_TABLE} WHERE id=%s", + (bag_id,), + ) + if not rows: + raise RuntimeError(f"bag_list not found: id={bag_id}") + name, path = rows[0] + if not path: + raise RuntimeError(f"bag data_path empty: id={bag_id}") + return str(name), str(path) + + +def is_parent_done(parent_name: str) -> bool: + rows = db_fetchall( + f""" + SELECT 1 FROM {JOINED_PANGU_TABLE} + WHERE name=%s AND data_path IS NOT NULL AND data_path<>'' + LIMIT 1 + """, + (parent_name,), + ) + return bool(rows) + + +def upsert_joined_pangu(name: str, data_path: str): + db_execute( + f""" + INSERT INTO {JOINED_PANGU_TABLE} (name, data_path) + VALUES (%s, %s) + ON CONFLICT (name) + DO UPDATE SET data_path=EXCLUDED.data_path + """, + (name, data_path), + ) + + +# ========================================================= +# ROS helpers +# ========================================================= +def ros_fix(src: Path) -> Path: + if not AUTO_FIX: + return src + fixed = src.with_suffix(src.suffix + ".fixed.bag") + run_cmd(["rosbag", "fix", str(src), str(fixed)], timeout=COSCMD_TIMEOUT) + return fixed + + +def ros_merge(out_bag: Path, inputs: list[Path]): + run_cmd( + ["/app/merge_ros1.sh", str(out_bag)] + [str(p) for p in inputs], + timeout=COSCMD_TIMEOUT, + ) + + +# ========================================================= +# Worker +# ========================================================= +def work_one(parent_id: int): + start = time.time() + + parent_name, parent_key = fetch_bag_meta(parent_id) + + if is_parent_done(parent_name): + log.info( + "[SKIP] parent already done | parent_id=%s name=%s", parent_id, parent_name + ) + return + + log.info("[START] parent | parent_id=%s name=%s", parent_id, parent_name) + + children = fetch_children_ids(parent_id) + log.info("[CHILDREN] parent_id=%s count=%d", parent_id, len(children)) + + if not children: + log.warning("[EMPTY] no children | parent_id=%s", parent_id) + return + + wd = TEMP_ROOT / f"parent_{parent_id}_{safe_name(parent_name)}" + wd.mkdir(parents=True, exist_ok=True) + + try: + local_inputs = [] + + for cid in children: + cname, ckey = fetch_bag_meta(cid) + lp = wd / safe_name(ckey) + log.info("[DOWNLOAD] parent_id=%s child_id=%s key=%s", parent_id, cid, ckey) + cos_download(ckey, lp) + local_inputs.append(ros_fix(lp)) + + merged_local = wd / f"{safe_name(parent_name)}.bag" + log.info("[MERGE] start | parent_id=%s", parent_id) + ros_merge(merged_local, local_inputs) + log.info("[MERGE] done | parent_id=%s", parent_id) + + merged_key = f"{MERGED_PREFIX}/{parent_name}.bag" + log.info("[UPLOAD] parent_id=%s key=%s", parent_id, merged_key) + url = cos_upload(merged_local, merged_key) + + upsert_joined_pangu(parent_name, url) + cost = time.time() - start + log.info( + "[DONE] parent | parent_id=%s name=%s cost=%.2fs", + parent_id, + parent_name, + cost, + ) + + except Exception as e: + log.exception( + "[FAILED] parent | parent_id=%s name=%s error=%s", parent_id, parent_name, e + ) + raise + finally: + shutil.rmtree(wd, ignore_errors=True) + + +# ========================================================= +# Main +# ========================================================= +def main(): + TEMP_ROOT.mkdir(parents=True, exist_ok=True) + + log.info( + "runner start | workers=%s TEMP_ROOT=%s AUTO_FIX=%s", + MAX_WORKERS, + TEMP_ROOT, + AUTO_FIX, + ) + + run_cmd([COSCMD_BIN, "--version"], timeout=30) + run_cmd(["rosbag", "info", "--help"], timeout=30) + + parents = fetch_parent_ids() + log.info("parents found: %d", len(parents)) + if not parents: + return + + with ProcessPoolExecutor(max_workers=MAX_WORKERS) as pool: + futures = {pool.submit(work_one, pid): pid for pid in parents} + for fu in as_completed(futures): + pid = futures[fu] + try: + fu.result() + except Exception: + log.error("parent failed | parent_id=%s", pid) + + +if __name__ == "__main__": + main() diff --git a/infra/volc/Dockerfile.bag_copy b/infra/volc/Dockerfile.bag_copy new file mode 100644 index 0000000..62d6f21 --- /dev/null +++ b/infra/volc/Dockerfile.bag_copy @@ -0,0 +1,5 @@ +FROM alpine:latest +RUN apk add --no-cache bash rsync +COPY fst_data_pipeline/pipelines/volc/bag-copy.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/infra/volc/Dockerfile.bag_scanner b/infra/volc/Dockerfile.bag_scanner new file mode 100644 index 0000000..1c1c7e8 --- /dev/null +++ b/infra/volc/Dockerfile.bag_scanner @@ -0,0 +1,3 @@ +FROM python:3.12-alpine +COPY fst_data_pipeline/pipelines/volc/bag_scanner.py /bag_scanner.py +RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ requests \ No newline at end of file diff --git a/infra/volc/nginx/logo/API.png b/infra/volc/nginx/logo/API.png new file mode 100644 index 0000000..3c79256 Binary files /dev/null and b/infra/volc/nginx/logo/API.png differ diff --git a/infra/volc/nginx/logo/browser.png b/infra/volc/nginx/logo/browser.png new file mode 100644 index 0000000..31298c0 Binary files /dev/null and b/infra/volc/nginx/logo/browser.png differ diff --git a/infra/volc/nginx/logo/docker.png b/infra/volc/nginx/logo/docker.png new file mode 100644 index 0000000..016f31f Binary files /dev/null and b/infra/volc/nginx/logo/docker.png differ diff --git a/infra/volc/nginx/logo/fst_an.png b/infra/volc/nginx/logo/fst_an.png new file mode 100644 index 0000000..d7a910b Binary files /dev/null and b/infra/volc/nginx/logo/fst_an.png differ diff --git a/infra/volc/nginx/logo/grafana.png b/infra/volc/nginx/logo/grafana.png new file mode 100644 index 0000000..f4ffd7d Binary files /dev/null and b/infra/volc/nginx/logo/grafana.png differ diff --git a/infra/volc/nginx/logo/main.png b/infra/volc/nginx/logo/main.png new file mode 100644 index 0000000..749b3b9 Binary files /dev/null and b/infra/volc/nginx/logo/main.png differ diff --git a/infra/volc/nginx/logo/mbviz.png b/infra/volc/nginx/logo/mbviz.png new file mode 100644 index 0000000..14de907 Binary files /dev/null and b/infra/volc/nginx/logo/mbviz.png differ diff --git a/infra/volc/nginx/logo/pg.png b/infra/volc/nginx/logo/pg.png new file mode 100644 index 0000000..e2469fc Binary files /dev/null and b/infra/volc/nginx/logo/pg.png differ diff --git a/infra/volc/nginx/logo/rosetta.png b/infra/volc/nginx/logo/rosetta.png new file mode 100644 index 0000000..9fb2f8a Binary files /dev/null and b/infra/volc/nginx/logo/rosetta.png differ diff --git a/infra/volc/nginx/logo/test.png b/infra/volc/nginx/logo/test.png new file mode 100644 index 0000000..8a7958a Binary files /dev/null and b/infra/volc/nginx/logo/test.png differ diff --git a/infra/volc/nginx/logo/tile.png b/infra/volc/nginx/logo/tile.png new file mode 100644 index 0000000..88a5711 Binary files /dev/null and b/infra/volc/nginx/logo/tile.png differ diff --git a/infra/volc/nginx/logo/vault.png b/infra/volc/nginx/logo/vault.png new file mode 100644 index 0000000..6d39b1c Binary files /dev/null and b/infra/volc/nginx/logo/vault.png differ diff --git a/infra/volc/nginx/logo/vlm.png b/infra/volc/nginx/logo/vlm.png new file mode 100644 index 0000000..4fd5b92 Binary files /dev/null and b/infra/volc/nginx/logo/vlm.png differ diff --git a/infra/volc/nginx/nav.html b/infra/volc/nginx/nav.html new file mode 100644 index 0000000..31ee713 --- /dev/null +++ b/infra/volc/nginx/nav.html @@ -0,0 +1,99 @@ + + + + + + FST ToolKit + + + +
    离线模式
    +
    +

    FST ToolKit

    +
    +
    + + + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bdc02fc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +[project] +name = "fst_data_pipeline" +version = "0.1.0" +description = "cloud infra pipeline for mb fst data production" +authors = [{ name = "MBRDCA", email = "tbd@tbd.com" }] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "cos-python-sdk-v5==1.9.37", + "flask>=3.1.1", + "folium==0.20.0", + "numpy==2.3.1", + "prometheus-client==0.22.1", + "pytz==2025.2", + "tenacity==9.1.2", + "tqdm==4.67.1", + "dotenv==0.9.9", + "geoalchemy2==0.17.1", + "psycopg2-binary==2.9.10", + "shapely>=2.1.1", + "sqlalchemy==2.0.41", + "annotated-types==0.7.0", + "attrs==25.3.0", + "blinker==1.9.0", + "branca==0.8.1", + "certifi==2025.7.14", + "charset-normalizer==3.4.2", + "click==8.2.1", + "crcmod==1.7", + "flasgger==0.9.7b2", + "greenlet==3.2.3", + "idna==3.10", + "itsdangerous==2.2.0", + "jinja2==3.1.6", + "jsonschema==4.24.0", + "jsonschema-specifications==2025.4.1", + "markupsafe==3.0.2", + "mistune==3.1.3", + "packaging==25.0", + "pip==25.1.1", + "pycryptodome==3.23.0", + "pydantic==2.11.7", + "pydantic-core==2.33.2", + "python-dotenv==1.1.1", + "pyyaml==6.0.2", + "referencing==0.36.2", + "requests==2.32.4", + "rpds-py==0.26.0", + "ruff==0.12.3", + "six==1.17.0", + "typing-extensions==4.14.1", + "typing-inspection==0.4.1", + "urllib3==2.5.0", + "uv==0.7.20", + "werkzeug==3.1.3", + "xmltodict==0.14.2", + "xyzservices==2025.4.0", + "iniconfig==2.1.0", + "pluggy==1.6.0", + "pygments==2.19.2", + "pytest==8.4.1", + "pytest-mock==3.14.0", + "pytest-cov==6.2.0", + "gunicorn>=23.0.0", + "flask-caching>=2.3.1", + "redis>=6.4.0", + "openpyxl>=3.1.5", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["fst_data_pipeline"] + +[[tool.uv.index]] +url = "https://mirrors.aliyun.com/pypi/simple" +default = true + +[tool.uv.workspace] +members = [ + "fst_data_pipeline/apps/mta_manage_system", + "root_db_api", + "fst_tools" +] + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..f18a052 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,4 @@ +# Tools and scripts +manual execute tools and scripts in fst data production line. + +* fst_sql_tool: tools to insert a txt tree into database. \ No newline at end of file diff --git a/scripts/fst_sql_tool/FST_APA_tree.txt b/scripts/fst_sql_tool/FST_APA_tree.txt new file mode 100644 index 0000000..cc0e37f --- /dev/null +++ b/scripts/fst_sql_tool/FST_APA_tree.txt @@ -0,0 +1,33 @@ +APA FST +├── APA_SEARCHING_CORNER_CASE +│ ├── APA_SEARCHING_CORNER_CASE_COLOR_BLOCK +│ ├── APA_SEARCHING_CORNER_CASE_DEAD_END +│ ├── APA_SEARCHING_CORNER_CASE_GREEN_BELT +│ ├── APA_SEARCHING_CORNER_CASE_RAMP +│ └── APA_SEARCHING_CORNER_CASE_UNCLEAR_LINE +├── APA_SEARCHING_NON_SLOT +│ ├── NO_PARKING_GROUNDLINE +│ │ ├── NO_PARKING_GUIDE_MARKINGS +│ │ ├── NO_PARKING_SOLID_YELLOW_CURB_LINE +│ │ └── NO_PARKING_YELLOW_GRID_MARKINGS +│ ├── NO_PARKING_SIGN +│ └── SPECIFIC_AREA_NO_PARKING +│ ├── NO_PARKING_BICYCLE_LANES +│ ├── NO_PARKING_BLIND_PATH +│ ├── NO_PARKING_CROSSWALKS +│ ├── NO_PARKING_DISABLED +│ └── NO_PARKING_FIRE_LANES +├── APA_SEARCHING_PARALLEL +│ ├── APA_SEARCHING_PARALLEL_FUSED +│ ├── APA_SEARCHING_PARALLEL_LINE +│ └── APA_SEARCHING_PARALLEL_SPACE +├── APA_SEARCHING_PERPENDICULAR +│ ├── APA_SEARCHING_PERPENDICULAR _LINE +│ ├── APA_SEARCHING_PERPENDICULAR _SPACE +│ └── APA_SEARCHING_PERPENDICULAR_FUSED +├── APA_SEARCHING_ROUNDABOUT +│ ├── APA_SEARCHING_ROUNDABOUT_PARALLEL +│ └── APA_SEARCHING_ROUNDABOUT_PERPENDICULAR +└── APA_SEARCHING_SLANT + ├── APA_SEARCHING_SLANT_NEGATIVE + └── APA_SEARCHING_SLANT_POSITIVE diff --git a/scripts/fst_sql_tool/FST_tree.txt b/scripts/fst_sql_tool/FST_tree.txt new file mode 100644 index 0000000..be63aa1 --- /dev/null +++ b/scripts/fst_sql_tool/FST_tree.txt @@ -0,0 +1,384 @@ +MB Driving FST V1 +├── CLOSING_ADJACENT_LANE +│ ├── CLOSING_ADJACENT_LANE_HEAVY_TRAFFIC_COOPERATRIVE_DRIVING +│ ├── CLOSING_ADJACENT_LANE_LANE_CHANGE_POSSIBLE +│ └── CLOSING_ADJACENT_LANE_PASSINGBY_CLOSING_LANE +│ ├── CLOSING_ADJACENT_LANE_PASSINGBY_CLOSING_LANE_GET_INFRONT_OBJECT +│ └── CLOSING_ADJACENT_LANE_PASSINGBY_CLOSING_LANE_KEEP_BEHIND_OBJECT +├── CLOSING_EGO_LANE +│ ├── CLOSING_EGO_LANE_MERGED_INTO_AL_OCCUPIED +│ │ ├── CLOSING_EGO_LANE_MERGED_INTO_AL_HEAVY_TRAFFIC +│ │ └── CLOSING_EGO_LANE_MERGED_INTO_AL_STATIC_CAR +│ └── CLOSING_EGO_LANE_MERGED_INTO_AL_UNOCCUPIED +├── CONSTRUCTION_SITE +│ ├── CONSTRUCTION_SITE_BRAKE_TO_STANDSTILL_BEFORE_CONE +│ ├── CONSTRUCTION_SITE_BRAKE_TO_STANDSTILL_BEFORE_PLASTIC_BARRIER +│ ├── CONSTRUCTION_SITE_BRAKE_TO_STANDSTILL_BEFORE_WORKZONE_BOARD +│ ├── CONSTRUCTION_SITE_FOLLOW_CHICANE_LANE +│ ├── CONSTRUCTION_SITE_LANE_CHANGE_TO_AVOID_CONE +│ ├── CONSTRUCTION_SITE_LANE_CHANGE_TO_AVOID_PLASTIC_BARRIER +│ └── CONSTRUCTION_SITE_LANE_CHANGE_TO_AVOID_WORKZONE_BOARD +├── CROSS_HANDLING +│ ├── LARGE_VEHICLE_CROSS_HANDLING +│ │ ├── GO_STRAIGHT_LARGE_VEHICLE_CROSS_DURING_REVERSING +│ │ ├── LARGE_VEHICLE_CROSS_GO_STRAIGHT +│ │ ├── LARGE_VEHICLE_CROSS_LEFT_TURN +│ │ ├── LARGE_VEHICLE_CROSS_RIGHT_TURN +│ │ └── LARGE_VEHICLE_CROSS_ROADGAP +│ ├── SMALL_VEHICLE_CROSS_HANDLING +│ │ ├── OCCLUDED_SMALL_VEHICLE_CROSS_GO_STRAIGHT +│ │ ├── SMALL_VEHICLE_CROSS_DURING_REVERSING +│ │ ├── SMALL_VEHICLE_CROSS_DURING_UTURN +│ │ ├── SMALL_VEHICLE_CROSS_GO_STRAIGHT +│ │ ├── SMALL_VEHICLE_CROSS_GO_STRAIGHT_ROADGAP +│ │ ├── SMALL_VEHICLE_CROSS_LEFT_TURN +│ │ ├── SMALL_VEHICLE_CROSS_RIGHT_TURN +│ │ └── SMALL_VEHICLE_CROSS_TRAFFIC_JAM +│ └── VRU_CROSS_HANDLING +│ ├── OCCLUSION_VRU_CROSS +│ │ ├── OCCLUDEDBYCAR_VRU_CROSS +│ │ └── OCCLUDEDBYWALL_VRU_CROSS +│ ├── VRU_CROSS_CLOSE +│ ├── VRU_CROSS_FAR +│ ├── VRU_CROSS_GO_STRAIGHT +│ ├── VRU_CROSS_IN_ROAD_GAP +│ ├── VRU_CROSS_LEFT_TURN +│ └── VRU_CROSS_RIGHT_TURN +├── CRUISE_ADJACENT_TRAFFIC +│ ├── CRUISE_ADJAC_TRAFFIC_CONVOY_ON_BOTH_SIDES +│ │ ├── CRUISE_ADJAC_TRAFFIC_CONVOY_ON_BOTH_SIDES_HAVING_DIFFERENT_SPD +│ │ └── CRUISE_ADJAC_TRAFFIC_CONVOY_ON_BOTH_SIDES_HAVING_SIMILAR_SPD +│ ├── CRUISE_ADJAC_TRAFFIC_CONVOY_ON_LFT_EGO_PASSES_ON_RGT +│ │ ├── CRUISE_ADJAC_TRAFFIC_FAST_CONVOY_ON_LFT_EGO_PASSES_ON_RGT +│ │ └── CRUISE_ADJAC_TRAFFIC_SLOW_CONVOY_ON_LFT_EGO_PASSES_ON_RGT +│ ├── CRUISE_ADJAC_TRAFFIC_CONVOY_ON_RGT_EGO_PASSES_ON_LFT_MOST +│ │ ├── CRUISE_ADJAC_TRAFFIC_FAST_CONVOY_ON_RGT_EGO_PASSES_ON_LFT_MOST +│ │ └── CRUISE_ADJAC_TRAFFIC_SLOW_CONVOY_ON_RGT_EGO_PASSES_ON_LFT_MOST +│ └── CRUISE_ADJAC_TRAFFIC_CONVOY_ON_RIGHT_EGO_PASSES_ON_LFT +│ ├── CRUISE_ADJAC_TRAFFIC_FAST_CONVOY_ON_RIGHT_EGO_PASSES_ON_LFT +│ └── CRUISE_ADJAC_TRAFFIC_SLOW_CONVOY_ON_RIGHT_EGO_PASSES_ON_LFT +├── CRUISE_MOTION +│ ├── CRUISE_MOTION_ACCEL +│ │ ├── CRUISE_MOTION_ACTIVATE_SLOWER_THAN_SETSPD_ACCEL +│ │ ├── CRUISE_MOTION_CUTOUT_BEHIND_SLOW_RPV_ACCEL +│ │ └── CRUISE_MOTION_STARTUP_SETSPD_50KPH_ACCEL +│ ├── CRUISE_MOTION_DECEL +│ │ ├── CRUISE_MOTION_ACTIVATE_FASTER_THAN_SETSPD_DECEL +│ │ └── CRUISE_MOTION_DRIVER_OVR_CC_THEN_RELEASE_DECEL +│ ├── CRUISE_MOTION_SETSPD +│ │ ├── CRUISE_MOTION_SETSPD_DECREASE_EQUAL_10KPH_DECEL +│ │ ├── CRUISE_MOTION_SETSPD_DECREASE_MORETHAN_10KPH_DECEL +│ │ ├── CRUISE_MOTION_SETSPD_INCREASE_EQUAL_10KPH_ACCEL +│ │ └── CRUISE_MOTION_SETSPD_INCREASE_MORETHAN_10KPH_ACCEL +│ └── CRUISE_MOTION_SLOPE +│ ├── CRUISE_MOTION_CCMODE_KEEP_SETSPD +│ ├── CRUISE_MOTION_SLOWDOWN_DESCEND_DECEL +│ └── CRUISE_MOTION_SPEEDUP_ ASCEND_ACCEL +├── CRUISE_SPDLIM +│ ├── CRUISE_SPDLIM_DRIVER +│ │ ├── CRUISE_REPEATED_SPD_LIMIT_KEEP_MODIFIED_SETSPD +│ │ ├── CRUISE_SPDLIM_CHANGE_DRIVER_INTERVENT_RESET_SETSPD +│ │ └── CRUISE_SPDLIM_TAKEOVER_VIA_RESUME +│ ├── CRUISE_SPDLIM_LANE_LVL +│ │ ├── CRUISE_UPCOMING_SPDLIM_LANE_LVL_LANECHG_TO_FASTER +│ │ ├── CRUISE_UPCOMING_SPDLIM_LANE_LVL_LANECHG_TO_SLOWER +│ │ └── CRUISE_UPCOMING_SPDLIM_LANE_LVL_UNMOD_SETSPD +│ │ ├── CRUISE_UPCOMING_SPDLIM_FASTLANE_LVL +│ │ ├── CRUISE_UPCOMING_SPDLIM_MIDDLELANE +│ │ └── CRUISE_UPCOMING_SPDLIM_SLOWLANE_LVL +│ ├── CRUISE_SPDLIM_PERCEPTION +│ │ ├── CRUISE_SPDLIM_NO_TAKEOVER_FROM_TRAFFICAGENTS +│ │ └── CRUISE_SPDLIM_TAKEOVER_FROM_TEMPORARY_SIGNS +│ └── CRUISE_SPDLIM_UPCOMING +│ ├── CRUISE_SPDLIM_CHG_TO_HIGHER_VALUE +│ │ ├── CRUISE_SPDLIM_CHG_SETSPD_BETW_OLD_AND_NEW_LIMIT +│ │ ├── CRUISE_SPDLIM_CHG_SETSPD_HIGHER_THAN_NEW_LIMIT +│ │ ├── CRUISE_SPDLIM_CHG_SETSPD_LOWER_THAN_OLD_LIMIT +│ │ └── CRUISE_SPDLIM_CHG_USE_NEW_LIMIT +│ └── CRUISE_SPDLIM_CHG_TO_LOWER_VALUE +│ └── CRUISE_SPDLIM_CHG_TAKEOVER_NEW_LIMIT +├── CRUISE_TRAFFIC_JAM_APPROACH +│ ├── CRUISE_TRAFFIC_JAM_APPROACH_MODERATE_TARGET_SPEED +│ └── CRUISE_TRAFFIC_JAM_APPROACH_SLOW_TARGET_SPEED +├── EGO_LANE_PENETRATION +│ ├── PENETRATION_POST_TOLL_BOOTH +│ ├── PENETRATION_STANDING_VEH_ON_SHOULDER_MAJOR_OVERLAP +│ ├── PENETRATION_STANDING_VEH_ON_SHOULDER_MINOR_OVERLAP +│ ├── PENETRATION_STANDING_VEH_ON_SHOULDER_REMARKABLE_OVERLAP +│ ├── PENETRATION_STANDING_WIDE_LANE_SMALL_OVERLAP +│ │ ├── PENETRATION_STANDING_WIDE_LANE_SMALL_OVERLAP_NO_OTHER_AGENTS +│ │ └── PENETRATION_STANDING_WIDE_LANE_SMALL_OVERLAP_WITH_DRIVING_AGENTS +│ └── PENETRATION_WIDE_LANE_SMALL_VEHICLE_NO_OVERTAKE +├── INTERSECTION +│ ├── INTERSECTION_GO_STRAIGHT +│ │ ├── ALIGNED_INTERSECTION_GO_STRAIGHT +│ │ │ ├── ALIGNED_INTERSECTION_GO_STRAIGHT_M2N +│ │ │ └── ALIGNED_INTERSECTION_GO_STRAIGHT_N2N +│ │ ├── INTERSECTION_GO_STRAIGHT_MULTI_EXITS +│ │ ├── INTERSECTION_GO_STRAIGHT_WITH_STRAIGHT_TRANSFER_AREA +│ │ └── UNALIGNED_INTERSECTION_GO_STRAIGHT +│ │ ├── UNALIGNED_INTERSECTION_GO_STRAIGHT_M2N +│ │ └── UNALIGNED_INTERSECTION_GO_STRAIGHT_N2N +│ ├── INTERSECTION_LEFT_TURN +│ │ ├── INTERSECTION_LEFT_TURN_1ST_LEFT_LANE +│ │ ├── INTERSECTION_LEFT_TURN_2ND_LEFT_LANE +│ │ ├── INTERSECTION_LEFT_TURN_3RD_LEFT_LANE +│ │ ├── INTERSECTION_LEFT_TURN_MOSTRIGHT_LANE +│ │ ├── INTERSECTION_LEFT_TURN_UNPROTECTED +│ │ └── INTERSECTION_LEFT_TURN_WAITING_AREA +│ └── INTERSECTION_RIGHT_TURN +│ ├── INTERSECTION_RIGHT_TURN_1ST_LEFT_LANE +│ ├── INTERSECTION_RIGHT_TURN_1ST_RIGHT_LANE +│ ├── INTERSECTION_RIGHT_TURN_2ND_RIGHT_LANE +│ └── INTERSECTION_RIGHT_TURN_DEDICATED_LANE +├── LANE_CHANGE _AVOID_EVENT +│ ├── LANE_CHANGE_AVOID_BUS_LANE +│ ├── LANE_CHANGE_AVOID_ENTRY_LANE +│ ├── LANE_CHANGE_AVOID_EXIT_LANE +│ ├── LANE_CHANGE_AVOID_OCCUPIED_VEHICLE +│ └── LANE_CHANGE_AVOID_SLOW_SL_LANE +├── LANE_CHANGE_INTERACTION +│ └── LANE_CHANGE_INTERACTION_DRIVER_TRIGGER +├── LANE_CHANGE_NAVIGATION +│ ├── LANE_CHANGE_NAV_HIGHWAYTORAMP +│ │ ├── LANE_CHANGE_NAV_HIGHWAYTORAMP_CONGESTED +│ │ │ ├── LANE_CHANGE_NAV_HIGHWAYTORAMP_CONGESTED_GAP_SEL_BIG_DISTANCE +│ │ │ ├── LANE_CHANGE_NAV_HIGHWAYTORAMP_CONGESTED_GAP_SEL_SMALL_DISTANCE +│ │ │ └── LANE_CHANGE_NAV_HIGHWAYTORAMP_CONGESTED_REDUCE_PASSINGBY_SPEED +│ │ └── LANE_CHANGE_NAV_HIGHWAYTORAMP_NON_CONGESTED +│ └── LANE_CHANGE_NAV_ON_CLOSINGLANE +│ ├── LANE_CHANGE_NAV_ON_CLOSINGLANE_CONGESTED +│ │ ├── LANE_CHANGE_NAV_ON_CLOSINGLANE_CONGESTED_GAP_SELECTION +│ │ └── LANE_CHANGE_NAV_ON_CLOSINGLANE_CONGESTED_STANDSTILL +│ └── LANE_CHANGE_NAV_ON_CLOSINGLANE_NON_CONGESTED +├── LANE_CHANGE_OVERTAKE +│ ├── LANE_CHANGE_OVERTAKE_BICYCLIST +│ ├── LANE_CHANGE_OVERTAKE_LARGE_VEHICLE +│ ├── LANE_CHANGE_OVERTAKE_SMALL_VEHICLE +│ └── LANE_CHANGE_OVERTAKE_TRICYCLIST +├── LANE_KEEPING_DODGING +│ ├── LANE_BORROWING_DODGE_AL +│ │ ├── LANE_BORROWING_DODGE_AL_MOVING_OBSTACLES +│ │ │ ├── LANE_BORROWING_DODGE_AL_MOVING_LARGE_VEHICLE +│ │ │ ├── LANE_BORROWING_DODGE_AL_MOVING_SMALL_VEHICLE_CLOSE_PARALLEL +│ │ │ ├── LANE_BORROWING_DODGE_AL_MOVING_SMALL_VEHICLE_DOOR_OPEN +│ │ │ ├── LANE_BORROWING_DODGE_AL_MOVING_SMALL_VEHICLE_PARKING +│ │ │ ├── LANE_BORROWING_DODGE_AL_MOVING_SMALL_VEHICLE_REVERSING_DRIVING +│ │ │ ├── LANE_BORROWING_DODGE_AL_MOVING_VRU +│ │ │ └── LANE_BORROWING_DODGE_AL_MOVING_VRU_REVERSE +│ │ └── LANE_BORROWING_DODGE_AL_STANDING_OBSTACLES +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_CONE_LANE +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_CONSTRUCTION_SIGNS +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_CONSTRUCTION_WALLS +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_FALLING_GOODS +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_GAURDRAIL +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_ROADEDGES +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_STATIC_CAR +│ │ │ ├── LANE_BORROWING_DODGE_AL_STANDING_STATIC_CAR_DEAD +│ │ │ └── LANE_BORROWING_DODGE_AL_STANDING_STATIC_CAR_TEMPORARY +│ │ ├── LANE_BORROWING_DODGE_AL_STANDING_TRAFFIC_PILLARS +│ │ └── LANE_BORROWING_DODGE_AL_STANDING_TRIANGLE_WARNING_SIGNS +│ ├── LANE_BORROWING_DODGE_OL +│ │ ├── LANE_BORROWING_DODGE_OL_OBJECT +│ │ └── LANE_BORROWING_DODGE_OL_SOLID_LINE +│ ├── LANE_BORROWING_DODGE_SOLID_LINE +│ └── LANE_BORROWING_DODGE_SPLIT_POINT +├── LANE_KEEPING_OFFSET_CORRECTION +│ ├── LANE_KEEPING_OFFSET_CORRECTION_TRAFFIC +│ │ ├── LANE_KEEPING_OFFSET_CORRECTION_OPPOSITE_DRIVING_DIRECTION +│ │ ├── LANE_KEEPING_OFFSET_CORRECTION_SAME_DRIVING_DIRECTION +│ │ └── LANE_KEEPING_OFFSET_CORRECTION_TRAFFIC_JAM_ON_SIDE new added +│ └── LANE_KEEPING_OFFSET_CORRECTION_TRAFFIC_BOUNDARIES +│ ├── LANE_KEEPING_OFFSET_CORRECTION_CONES +│ └── LANE_KEEPING_OFFSET_CORRECTION_GUARDRAILS +├── LANE_KEEPING_PERCEPTION +│ ├── LANE_KEEPING_PERCEPTION_BAD_LANE_MARKING +│ │ ├── LANE_KEEPING_PERCEPTION_BOTH_SIDES +│ │ └── LANE_KEEPING_PERCEPTION_ONE_SIDE +│ │ ├── LANE_KEEPING_PERCEPTION_ONE_SIDE_REGULAR_LANE_WIDTH +│ │ └── LANE_KEEPING_PERCEPTION_ONE_SIDE_WIDE_LANE_WIDTH +│ ├── LANE_KEEPING_PERCEPTION_ILLUMINATION +│ │ ├── LANE_KEEPING_PERCEPTION_ILLUMINATION_NIGHT +│ │ └── LANE_KEEPING_PERCEPTION_ILLUMINATION_TUNNEL +│ └── LANE_KEEPING_PERCEPTION_WEATHER_COND +│ ├── LANE_KEEPING_PERCEPTION_WEATHER_COND_FOG +│ ├── LANE_KEEPING_PERCEPTION_WEATHER_COND_RAIN +│ ├── LANE_KEEPING_PERCEPTION_WEATHER_COND_SNOWY_WEATHER +│ └── LANE_KEEPING_PERCEPTION_WEATHER_COND_SNOW_ON_ROAD +├── LANE_KEEPING_ROAD_SHAPE +│ ├── LANE_KEEPING_CURVED_ROAD +│ │ ├── LANE_KEEPING_CURVED_HIGHWAY +│ │ ├── LANE_KEEPING_LARGE_BEND_FEDERAL_ROAD +│ │ ├── LANE_KEEPING_SCURVE_FEDERAL_ROAD +│ │ ├── LANE_KEEPING_SMALL_BEND_HIGHWAY_ENTRY +│ │ ├── LANE_KEEPING_SMALL_BEND_HIGHWAY_EXIT +│ │ ├── LANE_KEEPING_STAIGHT_LANE +│ │ └── LANE_KEEPING_WIDE_BEND_ROAD_FEDERAL_ROAD +│ └── LANE_KEEPING_LANE_WIDTH +│ ├── LANE_KEEPING_LANE_WIDTH_NARROW_LANE_WIDTH +│ ├── LANE_KEEPING_LANE_WIDTH_REGULAR_LANE_WIDTH +│ └── LANE_KEEPING_LANE_WIDTH_WIDE_LANE_WIDTH +│ ├── LANE_KEEPING_LANE_WIDTH_WIDE_ENTRIES +│ ├── LANE_KEEPING_LANE_WIDTH_WIDE_EXITS +│ └── LANE_KEEPING_LANE_WIDTH_WIDE_INTERCHANGES +├── MERGE +│ ├── MERGE_2_TO_1 +│ ├── MERGE_3_TO_1 +│ ├── MERGE_LANE_GAP +│ ├── MERGE_LEFT_TURN +│ ├── MERGE_RIGHT_TURN +│ ├── MERGE_TRAFFIC_JAM +│ └── MERGE_WITH_ORIGINAL_LANE +├── OBJECT_CUTIN +│ ├── OBJECT_CUTIN_BIG_VEHICLES +│ │ ├── OBJECT_CUTIN_BIG_VEHICLES_COMPLETELY_POSITIONED_IN_FRONT +│ │ └── OBJECT_CUTIN_BIG_VEHICLES_PARTLY_ON_SIDE_WHEN_CUTTING_IN +│ ├── OBJECT_CUTIN_REGULAR_VEHICLES +│ │ ├── OBJECT_CUTIN_FLUENT_TRAFFIC_CUTIN_FASTER_THAN_EGO +│ │ ├── OBJECT_CUTIN_FLUENT_TRAFFIC_SAME_SPD_REGULAR_DIST +│ │ ├── OBJECT_CUTIN_FLUENT_TRAFFIC_SAME_SPD_SHORT_DIST +│ │ ├── OBJECT_CUTIN_SLOW_OBJ_ON_ADJ_LANE_AHEAD_OF_CUTIN +│ │ └── OBJECT_CUTIN_SUDDEN_AND_CLOSE_CUTIN +│ ├── OBJECT_CUTIN_SMALL_VEHICLES +│ │ └── OBJECT_CUTIN_HIGH_LATERAL_SPEED +│ └── OBJECT_CUTIN_STOP_AND_GO_TRAFFIC +│ ├── OBJECT_CUTIN_STOP_AND_GO_TRAFFIC_CUTIN_OBJ +│ └── OBJECT_CUTIN_STOP_AND_GO_TRAFFIC_FOLLOW_DENSLY +├── OBJECT_DISTANCE_CONTROL +│ ├── OBJECT_DISTCTRL_BIG_DISTANCE_RPV +│ ├── OBJECT_DISTCTRL_HIGH_LONGIT_DYN +│ │ ├── OBJECT_DISTCTRL_DYNAMIC_RPV +│ │ │ ├── OBJECT_DISTCTRL_STRONG_ACCEL_RPV +│ │ │ └── OBJECT_DISTCTRL_STRONG_DECEL_RPV +│ │ └── OBJECT_DISTCTRL_SLOW_OR_STANDING_HIDDEN_RPV +│ └── OBJECT_DISTCTRL_MODERATE_LONGIT_DYN +├── OBJECT_PERCEPTION_TYPES +│ ├── OBJECT_PERCEPTION_TYPESVRUs +│ │ ├── OBJECT_PERCEPTION_TYPES_BICYCLES_ESCOOTERS +│ │ └── OBJECT_PERCEPTION_TYPES_PEDESTRIANS +│ ├── OBJECT_PERCEPTION_TYPES_BUS +│ ├── OBJECT_PERCEPTION_TYPES_MOTORCYCLES +│ ├── OBJECT_PERCEPTION_TYPES_NON_OVERRIDABLE_OBSTACLES +│ │ ├── OBJECT_PERCEPTION_TYPES_WASHING_MASCHINE_SIZED_OBJECTS +│ │ └── OBJECT_PERCEPTION_TYPES_WHEEL +│ ├── OBJECT_PERCEPTION_TYPES_ONCOMING VEHICLES +│ │ └── OBJECT_PERCEPTION_TYPES_VEHICLES_DRIVING_WRONG_DIRECTION_TOWARDS_EGO +│ ├── OBJECT_PERCEPTION_TYPES_OVERRIDABLE_OBSTACLES +│ │ ├── OBJECT_PERCEPTION_TYPES_PLASTICBAGS +│ │ ├── OBJECT_PERCEPTION_TYPES_SPEEDBUMPERS +│ │ └── OBJECT_PERCEPTION_TYPES_SPEEDBUMPERS_LOAD_LEASHES +│ ├── OBJECT_PERCEPTION_TYPES_TRICYCLES +│ ├── OBJECT_PERCEPTION_TYPES_TRUCKS +│ │ ├── OBJECT_PERCEPTION_TYPES_FLAT_EMPTY_TRAILERS +│ │ ├── OBJECT_PERCEPTION_TYPES_TARPAULIN_TRAILERS +│ │ ├── OBJECT_PERCEPTION_TYPES_TARPAULIN_TRAILERS_WITH_VEHICLE_IMAGES +│ │ └── OBJECT_PERCEPTION_TYPES_TOWTRUCK_TOWING_VEHICLES +│ ├── OBJECT_PERCEPTION_TYPES_VEHICLES_MOVING_TOWARDS_EGO +│ │ ├── OBJECT_PERCEPTION_TYPES_VEHICLES_ASCEND_ROLL_BACKWARDS_FROM_STANDSTILL +│ │ └── OBJECT_PERCEPTION_TYPES_VEHICLES_DRIVING_BACKWARDS_TOWARDS_EGO +│ ├── OBJECT_PERCEPTION_TYPES_VEHICLES_SEVERAL_OBJECTS_ON_EGO_DRIVING_LANE +│ └── OBJECT_PERCEPTION_TYPES_VEHICLES_WITH_LOAD_EXCEEDING_LOADING_AREA +├── OBJECT_START_AND_STOP +│ ├── START_STOP_BRAKING_STANDSTILL +│ │ ├── START_STOP_ACCEL_TO_DECEL +│ │ ├── START_STOP_BRAKING_STANDING_RPV +│ │ ├── START_STOP_LINEAR_DECEL_RPV +│ │ └── START_STOP_PROGRESSIVE_DECEL_RPV +│ ├── START_STOP_STARTING_RPV +│ │ ├── START_STOP_QUICK_STARTING_RPV +│ │ └── START_STOP_SLOW_STARTING_RPV +│ └── START_STOP_TRAFFIC +│ ├── START_STOP_ACSCENT +│ ├── START_STOP_DESCENT +│ └── START_STOP_PLANE_ROAD +├── ROUNDABOUT_DRIVING +│ ├── ROUNDABOUT_ENTRY +│ ├── ROUNDABOUT_EXIT +│ │ ├── ROUNDABOUT_1ST_EXIT +│ │ │ ├── ROUNDABOUT_1ST_EXIT_NO_TRAFFIC_LIGHT +│ │ │ └── ROUNDABOUT_1ST_EXIT_TRAFFIC_LIGHT +│ │ ├── ROUNDABOUT_2ND_EXIT +│ │ │ ├── ROUNDABOUT_2ND_EXIT_NO_TRAFFIC_LIGHT +│ │ │ └── ROUNDABOUT_2ND_EXIT_TRAFFIC_LIGHT +│ │ ├── ROUNDABOUT_3RD_EXIT +│ │ │ ├── ROUNDABOUT_3RD_EXIT_NO_TRAFFIC_LIGHT +│ │ │ └── ROUNDABOUT_3RD_EXIT_TRAFFIC_LIGHT +│ │ └── ROUNDABOUT_4TH_EXIT +│ │ ├── ROUNDABOUT_4TH_EXIT_NO_TRAFFIC_LIGHT +│ │ └── ROUNDABOUT_4TH_EXIT_TRAFFIC_LIGHT +│ ├── ROUNDABOUT_LANE_CHANGE +│ └── ROUNDABOUT_LIGHT_STOP_AND_GO +├── SPLIT +│ ├── SPLIT_1_TO_X +│ │ ├── SPLIT_1_TO_2 +│ │ ├── SPLIT_1_TO_3 +│ │ └── SPLIT_M2N +│ ├── SPLIT_DEDICATED_LANE +│ │ ├── SPLIT_ENTER_DEDICATED_LEFT_TURN_LANE +│ │ └── SPLIT_ENTER_DEDICATED_RIGHT_TURN_LANE +│ ├── SPLIT_ENTER_MAIN_ROAD +│ │ ├── SPLIT_ENTER_MAIN_ROAD_CROSS_NON_MOTORIZED_LANE +│ │ └── SPLIT_ENTER_MAIN_ROAD_OCCLUSION +│ ├── SPLIT_ENTER_SUBROAD +│ │ ├── SPLIT_ENTER_SUBROAD_OCCLUSION +│ │ └── SPLIT_ENTER_SUBROAD_UNDER_EXPRESS +│ ├── SPLIT_EXIT_TUNNEL +│ ├── SPLIT_OBJECT +│ └── SPLIT_RB +│ ├── SPLIT_RB_BRIDGE +│ └── SPLIT_RB_CONE +├── TOLL_STATION +│ ├── TOLL_STATION_ENTER +│ │ ├── TOLL_STATION_ENTER_BRAKE_TO_STANDSTILL_BEFORE_CLOSED_ROD +│ │ ├── TOLL_STATION_ENTER_LANE_SELECTION_AT_SQUARE +│ │ │ ├── TOLL_STATION_ENTER_LANE_SELECTION_ETC_LANE +│ │ │ └── TOLL_STATION_ENTER_LANE_SELECTION_MANUAL_CHARING_LANE +│ │ └── TOLL_STATION_ENTER_LANE_SELECTION_BEFORE_SQUARE +│ │ ├── TOLL_STATION_PREFER_LEFT_LANE_WITH_ETC +│ │ └── TOLL_STATION_PREFER_RIGHT_LANE_WITHOUT_ETC +│ └── TOLL_STATION_LEAVE +│ ├── TOLL_STATION_LEAVE_DRIVE_OFF_AFTER_ROD_OPEN +│ ├── TOLL_STATION_LEAVE_PASSING_NO_LANE_MARKING_SQUARE_AFTER_ROD +│ └── TOLL_STATION_LEAVE_SET_SPEED_ADJUST_TO_60kph_AFTER_PASSING_ROD +├── TRAFFIC_LIGHT_START_AND_BRAKE +│ ├── TRAFFIC_LIGHT_BLACK_LIGHT_PASSING +│ │ ├── TRAFFIC_LIGHT_BLACK_LIGHT_PASSING_ABNORMAL +│ │ ├── TRAFFIC_LIGHT_BLACK_LIGHT_PASSING_HIDDEN_BY_BIGVEHICLE +│ │ └── TRAFFIC_LIGHT_BLACK_LIGHT_PASSING_UNUSED_LIGHT +│ ├── TRAFFIC_LIGHT_COUNTDOWN +│ ├── TRAFFIC_LIGHT_GREEN_LIGHT_FLASHING_BRAKE_AND_STOP +│ ├── TRAFFIC_LIGHT_GREEN_LIGHT_FLASHING_PASSING +│ ├── TRAFFIC_LIGHT_GREEN_LIGHT_PASSING +│ │ ├── TRAFFIC_LIGHT_GREEN_LIGHT_PASSING_GO_STRAIGHT +│ │ ├── TRAFFIC_LIGHT_GREEN_LIGHT_PASSING_TURN_LEFT +│ │ └── TRAFFIC_LIGHT_GREEN_LIGHT_PASSING_TURN_RIGHT +│ ├── TRAFFIC_LIGHT_GREEN_LIGHT_START +│ ├── TRAFFIC_LIGHT_RED_LIGHT_BRAKE_AND_STOP +│ │ └── TRAFFIC_LIGHT_RED_LIGHT_BRAKE_AS_FIRST_CAR +│ ├── TRAFFIC_LIGHT_RED_LIGHT_WAITING +│ │ ├── TRAFFIC_LIGHT_RED_LIGHT_WAITING_GO_STRAIGHT +│ │ └── TRAFFIC_LIGHT_RED_LIGHT_WAITING_TURN_LEFT +│ ├── TRAFFIC_LIGHT_YELLOW_LIGHT_BRAKE_AND_STOP +│ ├── TRAFFIC_LIGHT_YELLOW_LIGHT_FLASHING_PASSING +│ └── TRAFFIC_LIGHT_YELLOW_LIGHT_PASSING +└── U_TURN + ├── U_TURN_BEFORE_INTERSECTION + │ └── U_TURN_BEFORE_INTERSECTION_DASHED_LINE + ├── U_TURN_DEDICATED_LANE + │ ├── U_TURN_DEDICATED_LANE_SHORT_UTURN_LIGHT + │ ├── U_TURN_DEDICATED_LANE_WITHOUT_TRAFFIC_LIGHT + │ └── U_TURN_DEDICATED_LANE_WITH_TRAFFIC_LIGHT + │ ├── U_TURN_DEDICATED_LANE_CIRCLE_LIGHT + │ ├── U_TURN_DEDICATED_LANE_LEFT_TURN_LIGHT + │ └── U_TURN_DEDICATED_LANE_UTURN_LIGHT + ├── U_TURN_INTERSECTION + │ ├── U_TURN_INTERSECTION_LEFT_TURN + │ ├── U_TURN_INTERSECTION_NO_TRAFFIC_LIGHT + │ ├── U_TURN_INTERSECTION_ROUND_TRAFFIC_LIGHT + │ └── U_TURN_INTERSECTION_UTURN_LIGHT + ├── U_TURN_IN_ROAD + │ ├── U_TURN_IN_ROAD_DASHED_LINE + │ ├── U_TURN_IN_ROAD_GRID_LINE + │ └── U_TURN_IN_ROAD_OPENNING_GAP + ├── U_TURN_NARROW_ROAD + └── U_TURN_SPECIAL_CASE diff --git a/scripts/fst_sql_tool/generate_sql_from_xml.sql b/scripts/fst_sql_tool/generate_sql_from_xml.sql new file mode 100644 index 0000000..93861e3 --- /dev/null +++ b/scripts/fst_sql_tool/generate_sql_from_xml.sql @@ -0,0 +1,322 @@ +-- 1) 建表 +CREATE TABLE IF NOT EXISTS fst ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + parent_id INT REFERENCES fst(id), + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + reserved_json JSONB, + bag_sum INT +); + +-- 2) 抽取所有节点到临时表 nodes_temp +CREATE TEMP TABLE nodes_temp AS +WITH xml_data AS ( + VALUES ( + xmlparse(document $$ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-- xml文件粘贴在这里 + $$) + ) +) +SELECT + x.name, + x.parent_name +FROM xml_data, + xmltable( + '/map//node' + PASSING xml_data.column1 + COLUMNS + name TEXT PATH '@TEXT', + parent_name TEXT PATH 'parent::node()/@TEXT' + ) AS x +; + +-- 3) 第一步:只插入所有不同的 name,parent_id 暂时留 NULL +INSERT INTO fst(name) +SELECT DISTINCT name +FROM nodes_temp; + +-- 4) 第二步:根据 nodes_temp 补全 parent_id +UPDATE fst AS child +SET parent_id = parent.id +FROM nodes_temp AS t +JOIN fst AS parent + ON parent.name = t.parent_name +WHERE child.name = t.name +; + +-- 5) (可选)验证 +-- SELECT id,name,parent_id FROM fst ORDER BY id; \ No newline at end of file diff --git a/scripts/fst_sql_tool/print_tree.py b/scripts/fst_sql_tool/print_tree.py new file mode 100644 index 0000000..473e209 --- /dev/null +++ b/scripts/fst_sql_tool/print_tree.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import psycopg2 +import os +import sys +from dotenv import load_dotenv + + +load_dotenv("../../.env", override=True) +DB_CONFIG = { + "host": os.getenv("ROOT_DB_HOST", "localhost"), + "port": os.getenv("ROOT_DB_PORT", "5432"), + "dbname": os.getenv("ROOT_DB_NAME", "mydb"), + "user": os.getenv("ROOT_DB_USER", "postgres"), + "password": os.getenv("ROOT_DB_PASSWD", "postgres"), +} + + +def fetch_nodes(): + conn = psycopg2.connect(**DB_CONFIG) + cur = conn.cursor() + cur.execute("SELECT id, name, parent_id FROM fst") + rows = cur.fetchall() + cur.close() + conn.close() + return rows + + +def build_tree(rows): + nodes = {} + for id_, name, parent_id in rows: + nodes[id_] = {"id": id_, "name": name, "parent_id": parent_id, "children": []} + roots = [] + for n in nodes.values(): + if n["parent_id"] is None or n["parent_id"] not in nodes: + roots.append(n) + else: + nodes[n["parent_id"]]["children"].append(n) + + def sort_rec(n): + n["children"].sort(key=lambda x: x["name"]) + for c in n["children"]: + sort_rec(c) + + for r in roots: + sort_rec(r) + return roots + + +def print_subtree(node, prefix="", is_last=True, depth=0): + # depth==0: 根节点;depth>=1: 各层子节点 + if depth == 0: + # 根直接打印名字 + print(node["name"]) + else: + # 非根:打印前置 prefix + 连接符 + 名字 + connector = "└── " if is_last else "├── " + print(prefix + connector + node["name"]) + # 计算传给下一层孩子的前缀 + if depth == 0: + # 第一层孩子不缩进,prefix 仍然空 + child_prefix = "" + else: + # depth>=1:如果我是本层最后一个,用空格填充,否则保留竖线 + child_prefix = prefix + (" " if is_last else "│ ") + # 递归打印子节点 + cnt = len(node["children"]) + for idx, child in enumerate(node["children"]): + last = idx == cnt - 1 + print_subtree(child, child_prefix, last, depth + 1) + + +def main(): + rows = fetch_nodes() + if not rows: + print("fst 表中没有数据。", file=sys.stderr) + sys.exit(1) + roots = build_tree(rows) + for idx, r in enumerate(roots): + last_root = idx == len(roots) - 1 + print_subtree(r, prefix="", is_last=last_root, depth=0) + + +if __name__ == "__main__": + main() diff --git a/scripts/fst_sql_tool/sql/fst_apa_dump.sql b/scripts/fst_sql_tool/sql/fst_apa_dump.sql new file mode 100644 index 0000000..5ff98e7 --- /dev/null +++ b/scripts/fst_sql_tool/sql/fst_apa_dump.sql @@ -0,0 +1,37 @@ +INSERT INTO public.fst ("name",parent_id,update_time,reserved_json,bag_sum) VALUES + ('APA FST',NULL,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PERPENDICULAR',11,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PERPENDICULAR _LINE',16,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PERPENDICULAR_FUSED',16,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PERPENDICULAR _SPACE',16,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PARALLEL',11,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PARALLEL_LINE',17,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PARALLEL_FUSED',17,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_PARALLEL_SPACE',17,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_SLANT',11,'2025-07-31 11:25:28.015028+08',NULL,NULL); +INSERT INTO public.fst ("name",parent_id,update_time,reserved_json,bag_sum) VALUES + ('APA_SEARCHING_SLANT_POSITIVE',12,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_SLANT_NEGATIVE',12,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_ROUNDABOUT',11,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_ROUNDABOUT_PERPENDICULAR',30,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_ROUNDABOUT_PARALLEL',30,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_NON_SLOT',11,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_SIGN',3,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_GROUNDLINE',3,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_SOLID_YELLOW_CURB_LINE',28,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_YELLOW_GRID_MARKINGS',28,'2025-07-31 11:25:28.015028+08',NULL,NULL); +INSERT INTO public.fst ("name",parent_id,update_time,reserved_json,bag_sum) VALUES + ('NO_PARKING_GUIDE_MARKINGS',28,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('SPECIFIC_AREA_NO_PARKING',3,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_CROSSWALKS',24,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_BLIND_PATH',24,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_FIRE_LANES',24,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_BICYCLE_LANES',24,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('NO_PARKING_DISABLED',24,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_CORNER_CASE',11,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_CORNER_CASE_COLOR_BLOCK',33,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_CORNER_CASE_GREEN_BELT',33,'2025-07-31 11:25:28.015028+08',NULL,NULL); +INSERT INTO public.fst ("name",parent_id,update_time,reserved_json,bag_sum) VALUES + ('APA_SEARCHING_CORNER_CASE_UNCLEAR_LINE',33,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_CORNER_CASE_DEAD_END',33,'2025-07-31 11:25:28.015028+08',NULL,NULL), + ('APA_SEARCHING_CORNER_CASE_RAMP',33,'2025-07-31 11:25:28.015028+08',NULL,NULL); diff --git a/scripts/fst_sql_tool/sql/fst_dump.sql b/scripts/fst_sql_tool/sql/fst_dump.sql new file mode 100644 index 0000000..cb588aa --- /dev/null +++ b/scripts/fst_sql_tool/sql/fst_dump.sql @@ -0,0 +1,251 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 15.12 (Debian 15.12-1.pgdg120+1) +-- Dumped by pg_dump version 15.12 (Debian 15.12-1.pgdg120+1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: fst; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE IF NOT EXISTS public.fst ( + id integer NOT NULL, + name character varying(255) NOT NULL, + parent_id integer, + update_time timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + reserved_json jsonb, + bag_sum integer default 0 +); + + +ALTER TABLE public.fst OWNER TO root_db_api; + +-- +-- Name: fst_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE IF NOT EXISTS public.fst_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.fst_id_seq OWNER TO root_db_api; + +-- +-- Name: fst_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.fst_id_seq OWNED BY public.fst.id; + + +-- +-- Name: fst id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.fst ALTER COLUMN id SET DEFAULT nextval('public.fst_id_seq'::regclass); + + +-- +-- Data for Name: fst; Type: TABLE DATA; Schema: public; Owner: postgres +-- +-- 根节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (591, 'MB Driving FST V1', NULL, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- 第一层 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (542, 'CRUISE_SPDLIM', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (663, 'CRUISE_MOTION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (673, 'CRUISE_TRAFFIC_JAM_APPROACH', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (629, 'CRUISE_ADJACENT_TRAFFIC', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (531, 'OBJECT_DISTANCE_CONTROL', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (679, 'OBJECT_CUTIN', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (607, 'OBJECT_START_AND_STOP', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (691, 'OBJECT_PERCEPTION_TYPES', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (734, 'EGO_LANE_PENETRATION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (392, 'LANE_KEEPING_ROAD_SHAPE', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (558, 'LANE_KEEPING_LANE_WIDTH', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (385, 'LANE_KEEPING_PERCEPTION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (455, 'LANE_KEEPING_OFFSET_CORRECTION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (730, 'CLOSING_ADJACENT_LANE', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (393, 'TRAFFIC_LIGHT_START_AND_BRAKE', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (537, 'LANE_CHANGE_NAVIGATION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (726, 'LANE_CHANGE_OVERTAKE', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (550, 'LANE_CHANGE _AVOID_EVENT', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (681, 'LANE_CHANGE_INTERACTION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (732, 'CONSTRUCTION_SITE', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (504, 'LANE_KEEPING_DODGING', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (613, 'SPLIT', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (407, 'TOLL_STATION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (402, 'MERGE', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (564, 'CLOSING_EGO_LANE', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (395, 'U_TURN', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (633, 'CROSS_HANDLING', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (746, 'INTERSECTION', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (660, 'ROUNDABOUT_DRIVING', 591, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- CRUISE_SPDLIM 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (543, 'CRUISE_SPEED_LIMIT_RECOGNITION', 542, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (544, 'CRUISE_SPEED_LIMIT_COMPLIANCE', 542, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- CRUISE_MOTION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (664, 'CRUISE_SMOOTH_ACCELERATION', 663, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (665, 'CRUISE_SMOOTH_DECELERATION', 663, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- CRUISE_TRAFFIC_JAM_APPROACH 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (674, 'CRUISE_TRAFFIC_JAM_GAP', 673, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (675, 'CRUISE_TRAFFIC_JAM_SPEED', 673, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- CRUISE_ADJACENT_TRAFFIC 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (630, 'CRUISE_ADJACENT_SPEED_MATCH', 629, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (631, 'CRUISE_ADJACENT_SAFE_DISTANCE', 629, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- OBJECT_DISTANCE_CONTROL 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (532, 'OBJECT_DISTANCE_NEAR', 531, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (533, 'OBJECT_DISTANCE_FAR', 531, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- OBJECT_CUTIN 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (680, 'OBJECT_CUTIN_REACTION', 679, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- OBJECT_START_AND_STOP 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (608, 'OBJECT_STOP_DIST', 607, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (609, 'OBJECT_START_TIME', 607, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- OBJECT_PERCEPTION_TYPES 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (692, 'OBJECT_TYPE_VEHICLE', 691, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (693, 'OBJECT_TYPE_PEDESTRIAN', 691, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (694, 'OBJECT_TYPE_CYCLIST', 691, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- EGO_LANE_PENETRATION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (735, 'EGO_LANE_PARTIAL_PENETRATION', 734, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (736, 'EGO_LANE_FULL_PENETRATION', 734, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_KEEPING_ROAD_SHAPE 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (393, 'LANE_KEEPING_CURVE_HANDLING', 392, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (394, 'LANE_KEEPING_STRAIGHT_PATH', 392, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_KEEPING_LANE_WIDTH 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (559, 'LANE_KEEPING_NARROW_LANE', 558, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (560, 'LANE_KEEPING_WIDE_LANE', 558, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_KEEPING_PERCEPTION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (386, 'LANE_KEEPING_POOR_MARKINGS', 385, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (387, 'LANE_KEEPING_CLEAR_MARKINGS', 385, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_KEEPING_OFFSET_CORRECTION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (456, 'LANE_KEEPING_LEFT_OFFSET', 455, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (457, 'LANE_KEEPING_RIGHT_OFFSET', 455, '2025-06-25 16:09:06.386817+00', NULL, NULL); +-- +-- CLOSING_ADJACENT_LANE 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (731, 'CLOSING_ADJACENT_LANE_MERGE', 730, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- TRAFFIC_LIGHT_START_AND_BRAKE 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (394, 'TRAFFIC_LIGHT_DETECTION', 393, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (395, 'TRAFFIC_LIGHT_RESPONSE', 393, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_CHANGE_NAVIGATION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (538, 'LANE_CHANGE_FOLLOW_ROUTE', 537, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (539, 'LANE_CHANGE_EXIT_HIGHWAY', 537, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_CHANGE_OVERTAKE 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (727, 'LANE_CHANGE_OVERTAKE_SLOW', 726, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (728, 'LANE_CHANGE_OVERTAKE_BLOCK', 726, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_CHANGE _AVOID_EVENT 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (551, 'LANE_CHANGE_AVOID_OBSTACLE', 550, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (552, 'LANE_CHANGE_AVOID_SLOW', 550, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_CHANGE_INTERACTION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (682, 'LANE_CHANGE_INTERACTION_GIVE_WAY', 681, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (683, 'LANE_CHANGE_INTERACTION_FORCE', 681, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- CONSTRUCTION_SITE 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (733, 'CONSTRUCTION_SITE_BYPASS', 732, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- LANE_KEEPING_DODGING 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (505, 'LANE_KEEPING_DODGE_POTHOLE', 504, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (506, 'LANE_KEEPING_DODGE_DEBRIS', 504, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- SPLIT 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (614, 'SPLIT_DECISION_LEFT', 613, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (615, 'SPLIT_DECISION_RIGHT', 613, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- TOLL_STATION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (408, 'TOLL_STATION_PAY', 407, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (409, 'TOLL_STATION_PASS', 407, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- MERGE 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (403, 'MERGE_EARLY', 402, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (404, 'MERGE_LATE', 402, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- CLOSING_EGO_LANE 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (565, 'CLOSING_EGO_LANE_MERGE', 564, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- U_TURN 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (396, 'U_TURN_LEFT', 395, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (397, 'U_TURN_RIGHT', 395, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- CROSS_HANDLING 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (634, 'CROSS_HANDLING_STOP', 633, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (635, 'CROSS_HANDLING_PASS', 633, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- INTERSECTION 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (747, 'INTERSECTION_LEFT_TURN', 746, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (748, 'INTERSECTION_RIGHT_TURN', 746, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (749, 'INTERSECTION_STRAIGHT', 746, '2025-06-25 16:09:06.386817+00', NULL, NULL); + +-- ROUNDABOUT_DRIVING 子节点 +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (661, 'ROUNDABOUT_ENTRY', 660, '2025-06-25 16:09:06.386817+00', NULL, NULL); +INSERT INTO public.fst (id, name, parent_id, update_time, reserved_json, bag_sum) VALUES (662, 'ROUNDABOUT_EXIT', 660, '2025-06-25 16:09:06.386817+00', NULL, NULL); +-- Name: fst_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.fst_id_seq', 768, true); + + +-- +-- Name: fst fst_name_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.fst + ADD CONSTRAINT fst_name_key UNIQUE (name); + + +-- +-- Name: fst fst_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.fst + ADD CONSTRAINT fst_pkey PRIMARY KEY (id); + + +-- +-- Name: fst fst_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.fst + ADD CONSTRAINT fst_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.fst(id); + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/scripts/navinfo_meta_index/README.md b/scripts/navinfo_meta_index/README.md new file mode 100644 index 0000000..07f1384 --- /dev/null +++ b/scripts/navinfo_meta_index/README.md @@ -0,0 +1,2 @@ +# Tools to index input bags nav_info meta to database +@Zhang YI to update \ No newline at end of file diff --git a/sit-start.sh b/sit-start.sh new file mode 100644 index 0000000..7a918fd --- /dev/null +++ b/sit-start.sh @@ -0,0 +1,5 @@ +docker stop fst-root-db +docker rm fst-root-db +docker build -f infra/tencent/docker_ci/Dockerfile.root_db -t fst-root-db:latest . + docker run -d --name fst-root-db -p 5232:5232 -e DB_USER=admin -e DB_PASSWORD=LUOhui814 -e DB_BASE_URL=10.204.136.2:5432/fsq fst-root-db:latest + docker logs -f fst-root-db diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..5946056 --- /dev/null +++ b/start.sh @@ -0,0 +1,5 @@ +docker stop fst-root-db-dev +docker rm fst-root-db-dev +docker build -f infra/tencent/docker_ci/Dockerfile.root_db -t fst-root-db-dev:dev . + docker run -d --name fst-root-db-dev -p 15232:5232 -e DB_USER=admin -e DB_PASSWORD=LUOhui814 -e DB_BASE_URL=10.204.136.2:5432/fsq_dev fst-root-db-dev:dev + docker logs -f fst-root-db-dev diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bf336ec --- /dev/null +++ b/uv.lock @@ -0,0 +1,1617 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[manifest] +members = [ + "fst-data-pipeline", + "mta-management-system", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "apscheduler" +version = "3.10.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pytz" }, + { name = "setuptools" }, + { name = "six" }, + { name = "tzlocal" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/17/dcdd450038f9b3888acf462ce778532c26c683fa2d6343ba1c7b2a383ae2/APScheduler-3.10.0.tar.gz", hash = "sha256:a49fc23269218416f0e41890eea7a75ed6b284f10630dcfe866ab659621a3696" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0a/82/e8b6e7e2dfea46bd649ecaf8771fb6552232394fc9adf5c7f10655a87b95/APScheduler-3.10.0-py3-none-any.whl", hash = "sha256:575299f20073c60a2cc9d4fa5906024cdde33c5c0ce6087c4e3c14be3b50fdd4" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" }, +] + +[[package]] +name = "branca" +version = "0.8.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "jinja2" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e8/1d/bec5cb6669b7bf98b632b20bbbb25200bdc44298e7a39d588b0028a78300/branca-0.8.1.tar.gz", hash = "sha256:ac397c2d79bd13af0d04193b26d5ed17031d27609a7f1fab50c438b8ae712390" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f8/9d/91cddd38bd00170aad1a4b198c47b4ed716be45c234e09b835af41f4e717/branca-0.8.1-py3-none-any.whl", hash = "sha256:d29c5fab31f7c21a92e34bf3f854234e29fecdcf5d2df306b616f20d816be425" }, +] + +[[package]] +name = "cachelib" +version = "0.13.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516" }, +] + +[[package]] +name = "certifi" +version = "2025.7.14" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "cos-python-sdk-v5" +version = "1.9.37" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "crcmod" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "six" }, + { name = "xmltodict" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/ab/e8ee64748f3b9ecf835041c26c519d276ba868c4726daad7bbfdb6a12bcd/cos_python_sdk_v5-1.9.37.tar.gz", hash = "sha256:59b34b39e55e3afbe9f94c396b57a13c7055c7e1013e5de873bd985cf7ca3858" } + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61" }, +] + +[[package]] +name = "crcmod" +version = "1.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e" } + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298" }, + { url = "https://mirrors.aliyun.com/pypi/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3" }, +] + +[[package]] +name = "docker" +version = "6.1.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/73/f7c9a14e88e769f38cb7fb45aa88dfd795faa8e18aea11bababf6e068d5e/docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/db/be/3032490fa33b36ddc8c4b1da3252c6f974e7133f1a50de00c6b85cca203a/docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa" }, +] + +[[package]] +name = "flasgger" +version = "0.9.7b2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "flask" }, + { name = "jsonschema" }, + { name = "mistune" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cf/f4/7b17871ee710c16644b20a379e31998eddbd2a455251d4bd8da05e8e47ae/flasgger-0.9.7b2.tar.gz", hash = "sha256:5c4328b0ad7b5c3768f36a1f7eda5fb28c4495d2f1b3d90621670643fb57a549" } + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c" }, +] + +[[package]] +name = "flask-caching" +version = "2.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "cachelib" }, + { name = "flask" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e2/80/74846c8af58ed60972d64f23a6cd0c3ac0175677d7555dff9f51bf82c294/flask_caching-2.3.1.tar.gz", hash = "sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/00/bb/82daa5e2fcecafadcc8659ce5779679d0641666f9252a4d5a2ae987b0506/Flask_Caching-2.3.1-py3-none-any.whl", hash = "sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761" }, +] + +[[package]] +name = "flask-sqlalchemy" +version = "3.0.5" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c6/4e/0991354600fe3e1223cd9f025dbde900b1c1fe231762e18cdaffbe55938e/flask_sqlalchemy-3.0.5.tar.gz", hash = "sha256:c5765e58ca145401b52106c0f46178569243c5da25556be2c231ecc60867c5b1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d8/1d/c3c5afdaebd5d5f82d2c25762f5356416bd7bc109a550c79247134e48ca3/flask_sqlalchemy-3.0.5-py3-none-any.whl", hash = "sha256:cabb6600ddd819a9f859f36515bb1bd8e7dbf30206cc679d2b081dff9e383283" }, +] + +[[package]] +name = "folium" +version = "0.20.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "branca" }, + { name = "jinja2" }, + { name = "numpy" }, + { name = "requests" }, + { name = "xyzservices" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/76/84a1b1b00ce71f9c0c44af7d80f310c02e2e583591fe7d4cb03baecd0d3f/folium-0.20.0.tar.gz", hash = "sha256:a0d78b9d5a36ba7589ca9aedbd433e84e9fcab79cd6ac213adbcff922e454cb9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b5/a8/5f764f333204db0390362a4356d03a43626997f26818a0e9396f1b3bd8c9/folium-0.20.0-py2.py3-none-any.whl", hash = "sha256:f0bc2a92acde20bca56367aa5c1c376c433f450608d058daebab2fc9bf8198bf" }, +] + +[[package]] +name = "fst-data-pipeline" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "annotated-types" }, + { name = "attrs" }, + { name = "blinker" }, + { name = "branca" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "click" }, + { name = "cos-python-sdk-v5" }, + { name = "crcmod" }, + { name = "dotenv" }, + { name = "flasgger" }, + { name = "flask" }, + { name = "flask-caching" }, + { name = "folium" }, + { name = "geoalchemy2" }, + { name = "greenlet" }, + { name = "gunicorn" }, + { name = "idna" }, + { name = "iniconfig" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "numpy" }, + { name = "openpyxl" }, + { name = "packaging" }, + { name = "pip" }, + { name = "pluggy" }, + { name = "prometheus-client" }, + { name = "psycopg2-binary" }, + { name = "pycryptodome" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "pygments" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "python-dotenv" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "referencing" }, + { name = "requests" }, + { name = "rpds-py" }, + { name = "ruff" }, + { name = "shapely" }, + { name = "six" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, + { name = "tqdm" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "urllib3" }, + { name = "uv" }, + { name = "werkzeug" }, + { name = "xmltodict" }, + { name = "xyzservices" }, +] + +[package.metadata] +requires-dist = [ + { name = "annotated-types", specifier = "==0.7.0" }, + { name = "attrs", specifier = "==25.3.0" }, + { name = "blinker", specifier = "==1.9.0" }, + { name = "branca", specifier = "==0.8.1" }, + { name = "certifi", specifier = "==2025.7.14" }, + { name = "charset-normalizer", specifier = "==3.4.2" }, + { name = "click", specifier = "==8.2.1" }, + { name = "cos-python-sdk-v5", specifier = "==1.9.37" }, + { name = "crcmod", specifier = "==1.7" }, + { name = "dotenv", specifier = "==0.9.9" }, + { name = "flasgger", specifier = "==0.9.7b2" }, + { name = "flask", specifier = ">=3.1.1" }, + { name = "flask-caching", specifier = ">=2.3.1" }, + { name = "folium", specifier = "==0.20.0" }, + { name = "geoalchemy2", specifier = "==0.17.1" }, + { name = "greenlet", specifier = "==3.2.3" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "idna", specifier = "==3.10" }, + { name = "iniconfig", specifier = "==2.1.0" }, + { name = "itsdangerous", specifier = "==2.2.0" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "jsonschema", specifier = "==4.24.0" }, + { name = "jsonschema-specifications", specifier = "==2025.4.1" }, + { name = "markupsafe", specifier = "==3.0.2" }, + { name = "mistune", specifier = "==3.1.3" }, + { name = "numpy", specifier = "==2.3.1" }, + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "packaging", specifier = "==25.0" }, + { name = "pip", specifier = "==25.1.1" }, + { name = "pluggy", specifier = "==1.6.0" }, + { name = "prometheus-client", specifier = "==0.22.1" }, + { name = "psycopg2-binary", specifier = "==2.9.10" }, + { name = "pycryptodome", specifier = "==3.23.0" }, + { name = "pydantic", specifier = "==2.11.7" }, + { name = "pydantic-core", specifier = "==2.33.2" }, + { name = "pygments", specifier = "==2.19.2" }, + { name = "pytest", specifier = "==8.4.1" }, + { name = "pytest-cov", specifier = "==6.2.0" }, + { name = "pytest-mock", specifier = "==3.14.0" }, + { name = "python-dotenv", specifier = "==1.1.1" }, + { name = "pytz", specifier = "==2025.2" }, + { name = "pyyaml", specifier = "==6.0.2" }, + { name = "redis", specifier = ">=6.4.0" }, + { name = "referencing", specifier = "==0.36.2" }, + { name = "requests", specifier = "==2.32.4" }, + { name = "rpds-py", specifier = "==0.26.0" }, + { name = "ruff", specifier = "==0.12.3" }, + { name = "shapely", specifier = ">=2.1.1" }, + { name = "six", specifier = "==1.17.0" }, + { name = "sqlalchemy", specifier = "==2.0.41" }, + { name = "tenacity", specifier = "==9.1.2" }, + { name = "tqdm", specifier = "==4.67.1" }, + { name = "typing-extensions", specifier = "==4.14.1" }, + { name = "typing-inspection", specifier = "==0.4.1" }, + { name = "urllib3", specifier = "==2.5.0" }, + { name = "uv", specifier = "==0.7.20" }, + { name = "werkzeug", specifier = "==3.1.3" }, + { name = "xmltodict", specifier = "==0.14.2" }, + { name = "xyzservices", specifier = "==2025.4.0" }, +] + +[[package]] +name = "geoalchemy2" +version = "0.17.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/46/52/60214c086a57a7e3ea82241bab94e09827f2b7f7c2084fe6fc280c099b23/geoalchemy2-0.17.1.tar.gz", hash = "sha256:ff5bbe0db5a4ff979f321c8aa1a7556f444ea30cda5146189b1a177ae5bec69d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f5/82/e83e0f74cba8bbff9c12b2dea4693adb0cf89204012da48433ebd8e8c4d8/GeoAlchemy2-0.17.1-py3-none-any.whl", hash = "sha256:29f41b67d3a52df47821b695d31dec8600747c6ef4de62ee69811bde481dd2ae" }, +] + +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, +] + +[[package]] +name = "jsonschema" +version = "4.24.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f" }, +] + +[[package]] +name = "mistune" +version = "3.1.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c4/79/bda47f7dd7c3c55770478d6d02c9960c430b0cf1773b72366ff89126ea31/mistune-3.1.3.tar.gz", hash = "sha256:a7035c21782b2becb6be62f8f25d3df81ccb4d6fa477a6525b15af06539f02a0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/01/4d/23c4e4f09da849e127e9f123241946c23c1e30f45a88366879e064211815/mistune-3.1.3-py3-none-any.whl", hash = "sha256:1a32314113cff28aa6432e99e522677c8587fd83e3d51c29b82a52409c842bd9" }, +] + +[[package]] +name = "mta-management-system" +version = "0.1.0" +source = { editable = "fst_data_pipeline/apps/mta_manage_system" } +dependencies = [ + { name = "apscheduler" }, + { name = "click" }, + { name = "docker" }, + { name = "flask" }, + { name = "flask-sqlalchemy" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "paramiko" }, + { name = "pymysql" }, + { name = "python-dotenv" }, + { name = "werkzeug" }, + { name = "xmltodict" }, +] + +[package.metadata] +requires-dist = [ + { name = "apscheduler", specifier = "==3.10" }, + { name = "click", specifier = "==8.2.1" }, + { name = "docker", specifier = "==6.1.3" }, + { name = "flask", specifier = ">=3.1.1" }, + { name = "flask-sqlalchemy", specifier = "==3.0.5" }, + { name = "itsdangerous", specifier = "==2.2.0" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "paramiko", specifier = "==3.1.0" }, + { name = "pymysql", specifier = "==1.1.0" }, + { name = "python-dotenv", specifier = "==1.1.1" }, + { name = "werkzeug", specifier = "==3.1.3" }, + { name = "xmltodict", specifier = "==0.14.2" }, +] + +[[package]] +name = "numpy" +version = "2.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c6/56/71ad5022e2f63cfe0ca93559403d0edef14aea70a841d640bd13cdba578e/numpy-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2959d8f268f3d8ee402b04a9ec4bb7604555aeacf78b360dc4ec27f1d508177d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/65/2db52ba049813670f7f987cc5db6dac9be7cd95e923cc6832b3d32d87cef/numpy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:762e0c0c6b56bdedfef9a8e1d4538556438288c4276901ea008ae44091954e29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/dd/28fa3c17b0e751047ac928c1e1b6990238faad76e9b147e585b573d9d1bd/numpy-2.3.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:867ef172a0976aaa1f1d1b63cf2090de8b636a7674607d514505fb7276ab08fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/fc/84ea0cba8e760c4644b708b6819d91784c290288c27aca916115e3311d17/numpy-2.3.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4e602e1b8682c2b833af89ba641ad4176053aaa50f5cacda1a27004352dde943" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/b2/512b0c2ddec985ad1e496b0bd853eeb572315c0f07cd6997473ced8f15e2/numpy-2.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8e333040d069eba1652fb08962ec5b76af7f2c7bce1df7e1418c8055cf776f25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/45/c51cb248e679a6c6ab14b7a8e3ead3f4a3fe7425fc7a6f98b3f147bec532/numpy-2.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e7cbf5a5eafd8d230a3ce356d892512185230e4781a361229bd902ff403bc660" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/ff/feb4be2e5c09a3da161b412019caf47183099cbea1132fd98061808c2df2/numpy-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1b8f26d1086835f442286c1d9b64bb3974b0b1e41bb105358fd07d20872952" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/6d/ceafe87587101e9ab0d370e4f6e5f3f3a85b9a697f2318738e5e7e176ce3/numpy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee8340cb48c9b7a5899d1149eece41ca535513a9698098edbade2a8e7a84da77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/19/0fb49a3ea088be691f040c9bf1817e4669a339d6e98579f91859b902c636/numpy-2.3.1-cp312-cp312-win32.whl", hash = "sha256:e772dda20a6002ef7061713dc1e2585bc1b534e7909b2030b5a46dae8ff077ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/3e/e28f4c1dd9e042eb57a3eb652f200225e311b608632bc727ae378623d4f8/numpy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfecc7822543abdea6de08758091da655ea2210b8ffa1faf116b940693d3df76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/a8/8a5e9079dc722acf53522b8f8842e79541ea81835e9b5483388701421073/numpy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:7be91b2239af2658653c5bb6f1b8bccafaf08226a258caf78ce44710a0160d30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" }, +] + +[[package]] +name = "paramiko" +version = "3.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "pynacl" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e8/53/e614a5b7bcc658d20e6eff6ae068863becb06bf362c2f135f5c290d8e6a2/paramiko-3.1.0.tar.gz", hash = "sha256:6950faca6819acd3219d4ae694a23c7a87ee38d084f70c1724b0c0dbb8b75769" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/56/7c/9dd558ec0869fcecb661765d0a2504978dbfe85de24cbcccc847aa9b58e4/paramiko-3.1.0-py3-none-any.whl", hash = "sha256:f0caa660e797d9cd10db6fc6ae81e2c9b2767af75c3180fcd0e46158cd368d7f" }, +] + +[[package]] +name = "pip" +version = "25.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/59/de/241caa0ca606f2ec5fe0c1f4261b0465df78d786a38da693864a116c37f4/pip-25.1.1.tar.gz", hash = "sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/29/a2/d40fb2460e883eca5199c62cfc2463fd261f760556ae6290f88488c362c0/pip-25.1.1-py3-none-any.whl", hash = "sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, +] + +[[package]] +name = "prometheus-client" +version = "0.22.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5e/cf/40dde0a2be27cc1eb41e333d1a674a74ce8b8b0457269cc640fd42b07cf7/prometheus_client-0.22.1.tar.gz", hash = "sha256:190f1331e783cf21eb60bca559354e0a4d4378facecf78f5428c39b675d20d28" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/32/ae/ec06af4fe3ee72d16973474f122541746196aaa16cea6f66d18b963c6177/prometheus_client-0.22.1-py3-none-any.whl", hash = "sha256:cca895342e308174341b2cbf99a56bef291fbc0ef7b9e5412a0f26d653ba7094" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" }, +] + +[[package]] +name = "pymysql" +version = "1.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/41/9d/ee68dee1c8821c839bb31e6e5f40e61035a5278f7c1307dde758f0c90452/PyMySQL-1.1.0.tar.gz", hash = "sha256:4f13a7df8bf36a51e81dd9f3605fede45a4878fe02f9236349fd82a3f0612f96" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e5/30/20467e39523d0cfc2b6227902d3687a16364307260c75e6a1cb4422b0c62/PyMySQL-1.1.0-py3-none-any.whl", hash = "sha256:8969ec6d763c856f7073c4c64662882675702efcb114b4bcbb955aea3a069fa7" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/17/139b134cb36e496a62780b2ff19ea47fd834f2d180a32e6dd9210f4a8a77/pytest_cov-6.2.0.tar.gz", hash = "sha256:9a4331e087a0f5074dc1e19fe0485a07a462b346cbb91e2ac903ec5504abce10" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/aa/66/a38138fbf711b2b93592dfd7303bba561f6bc05f85361a0388c105ceb727/pytest_cov-6.2.0-py3-none-any.whl", hash = "sha256:bd19301caf600ead1169db089ed0ad7b8f2b962214330a696b8c85a0b497b2ff" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c" }, +] + +[[package]] +name = "rpds-py" +version = "0.26.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/aa/4456d84bbb54adc6a916fb10c9b374f78ac840337644e4a5eda229c81275/rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ea/86/90eb87c6f87085868bd077c7a9938006eb1ce19ed4d06944a90d3560fce2/rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/78/4469f24d34636242c924626082b9586f064ada0b5dbb1e9d096ee7a8e0c6/rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/91/c448ed45efdfdade82348d5e7995e15612754826ea640afc20915119734f/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/43/e5c86fef4be7f49828bdd4ecc8931f0287b1152c0bb0163049b3218740e7/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/34/e00f726a4d44f22d5c5fe2e5ddd3ac3d7fd3f74a175607781fbdd06fe375/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/1c/52dc20c31b147af724b16104500fba13e60123ea0334beba7b40e33354b4/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/77/87d7bfabfc4e821caa35481a2ff6ae0b73e6a391bb6b343db2c91c2b9844/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/d4/7f2200c2d3ee145b65b3cddc4310d51f7da6a26634f3ac87125fd789152a/rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/13/9fdd428b9c820869924ab62236b8688b122baa22d23efdd1c566938a39ba/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/e1/b69686c3bcbe775abac3a4c1c30a164a2076d28df7926041f6c0eb5e8d28/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/c9/1e3d8c8863c84a90197ac577bbc3d796a92502124c27092413426f670990/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/c5/90c569649057622959f6dcc40f7b516539608a414dfd54b8d77e3b201ac0/rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/16/19f5d9f2a556cfed454eebe4d354c38d51c20f3db69e7b4ce6cff904905d/rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/f0/7935e40b529c0e752dfaa7880224771b51175fce08b41ab4a92eb2fbdc7f/rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/67/bb62d0109493b12b1c6ab00de7a5566aa84c0e44217c2d94bee1bd370da9/rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/f3/34e6ae1925a5706c0f002a8d2d7f172373b855768149796af87bd65dcdb9/rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/83/1953a9d4f4e4de7fd0533733e041c28135f3c21485faaef56a8aadbd96b5/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/0e/983ed1b792b3322ea1d065e67f4b230f3b96025f5ce3878cc40af09b7533/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/7f/36c0925fff6f660a80be259c5b4f5e53a16851f946eb080351d057698528/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/45/cbf07fc03ba7a9b54662c9badb58294ecfb24f828b9732970bd1a431ed5c/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/b0/8fa5e36e58657997873fd6a1cf621285ca822ca75b4b3434ead047daa307/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/f7/b25437772f9f57d7a9fbd73ed86d0dcd76b4c7c6998348c070d90f23e315/rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/6b/63ffa55743dfcb4baf2e9e77a0b11f7f97ed96a54558fcb5717a4b2cd732/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/07/1f4f5e2886c480a2346b1e6759c00278b8a69e697ae952d82ae2e6ee5db0/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/bc/e6639f1b91c3a55f8c41b47d73e6307051b6e246254a827ede730624c0f8/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/4c/b3917c45566f9f9a209d38d9b54a1833f2bb1032a3e04c66f75726f28876/rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/0b/0851bdd6025775aaa2365bb8de0697ee2558184c800bfef8d7aef5ccde58/rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/e8/a47c64ed53149c75fb581e14a237b7b7cd18217e969c30d474d335105622/rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/bf/3d970ba2e2bcd17d2912cb42874107390f72873e38e79267224110de5e61/rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/9f/283e7e2979fc4ec2d8ecee506d5a3675fce5ed9b4b7cb387ea5d37c2f18d/rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/03/7e50423c04d78daf391da3cc4330bdb97042fc192a58b186f2d5deb7befd/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/00/d11ee60d4d3b16808432417951c63df803afb0e0fc672b5e8d07e9edaaae/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/b3/1069c394d9c0d6d23c5b522e1f6546b65793a22950f6e0210adcc6f97c3e/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/3b/c4fbf0926800ed70b2c245ceca99c49f066456755f5d6eb8863c2c51e6d0/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/b0/db69b52ca07413e568dae9dc674627a22297abb144c4d6022c6d78f1e5cc/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/e1/c65255ad5b63903e56b3bb3ff9dcc3f4f5c3badde5d08c741ee03903e951/rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/22/bb731077872377a93c6e93b8a9487d0406c70208985831034ccdeed39c8e/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/8b/393322ce7bac5c4530fb96fc79cc9ea2f83e968ff5f6e873f905c493e1c4/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/ae/769dc372211835bf759319a7aae70525c6eb523e3371842c65b7ef41c9c6/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/f9/4c43f9cc203d6ba44ce3146246cdc38619d92c7bd7bad4946a3491bd5b70/rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/8b/9286b7e822036a4a977f2f1e851c7345c20528dbd56b687bb67ed68a8ede/rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/07/029b7c45db910c74e182de626dfdae0ad489a949d84a468465cd0ca36355/rpds_py-0.26.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:da619979df60a940cd434084355c514c25cf8eb4cf9a508510682f6c851a4f7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/d1/9b3d3f986216b4d1f584878dca15ce4797aaf5d372d738974ba737bf68d6/rpds_py-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea89a2458a1a75f87caabefe789c87539ea4e43b40f18cff526052e35bbb4fdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/98/16d5e7bc9ec715fa9668731d0cf97f6b032724e61696e2db3d47aeb89214/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feac1045b3327a45944e7dcbeb57530339f6b17baff154df51ef8b0da34c8c12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/13/aa5e2b1ec5ab0e86a5c464d53514c0467bec6ba2507027d35fc81818358e/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b818a592bd69bfe437ee8368603d4a2d928c34cffcdf77c2e761a759ffd17d20" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/03/8021810b0e97923abdbab6474c8b77c69bcb4b2c58330777df9ff69dc559/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a8b0dd8648709b62d9372fc00a57466f5fdeefed666afe3fea5a6c9539a0331" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/b1/da8e61c87c2f3d836954239fdbbfb477bb7b54d74974d8f6fcb34342d166/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d3498ad0df07d81112aa6ec6c95a7e7b1ae00929fb73e7ebee0f3faaeabad2f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/bc/1fc173edaaa0e52c94b02a655db20697cb5fa954ad5a8e15a2c784c5cbdd/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4146ccb15be237fdef10f331c568e1b0e505f8c8c9ed5d67759dac58ac246" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/eb/3a9bb4bd90867d21916f253caf4f0d0be7098671b6715ad1cead9fe7bab9/rpds_py-0.26.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9a63785467b2d73635957d32a4f6e73d5e4df497a16a6392fa066b753e87387" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/16/e066dcdb56f5632713445271a3f8d3d0b426d51ae9c0cca387799df58b02/rpds_py-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de4ed93a8c91debfd5a047be327b7cc8b0cc6afe32a716bbbc4aedca9e2a83af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/22/ddbdec7eb82a0dc2e455be44c97c71c232983e21349836ce9f272e8a3c29/rpds_py-0.26.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:caf51943715b12af827696ec395bfa68f090a4c1a1d2509eb4e2cb69abbbdb33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/b4/95744085e65b7187d83f2fcb0bef70716a1ea0a9e5d8f7f39a86e5d83424/rpds_py-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a59e5bc386de021f56337f757301b337d7ab58baa40174fb150accd480bc953" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/37/6309a75e464d1da2559446f9c811aa4d16343cebe3dbb73701e63f760caa/rpds_py-0.26.0-cp314-cp314-win32.whl", hash = "sha256:92c8db839367ef16a662478f0a2fe13e15f2227da3c1430a782ad0f6ee009ec9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/6f/8e9c11214c46098b1d1391b7e02b70bb689ab963db3b19540cba17315291/rpds_py-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:b0afb8cdd034150d4d9f53926226ed27ad15b7f465e93d7468caaf5eafae0d37" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/af/9c4638994dd623d51c39892edd9d08e8be8220a4b7e874fa02c2d6e91955/rpds_py-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:ca3f059f4ba485d90c8dc75cb5ca897e15325e4e609812ce57f896607c1c0867" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/db/669a241144460474aab03e254326b32c42def83eb23458a10d163cb9b5ce/rpds_py-0.26.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5afea17ab3a126006dc2f293b14ffc7ef3c85336cf451564a0515ed7648033da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/2d/133f61cc5807c6c2fd086a46df0eb8f63a23f5df8306ff9f6d0fd168fecc/rpds_py-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69f0c0a3df7fd3a7eec50a00396104bb9a843ea6d45fcc31c2d5243446ffd7a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/bf/0e8fb4c05f70273469eecf82f6ccf37248558526a45321644826555db31b/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:801a71f70f9813e82d2513c9a96532551fce1e278ec0c64610992c49c04c2dad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/a8/060d24185d8b24d3923322f8d0ede16df4ade226a74e747b8c7c978e3dd3/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df52098cde6d5e02fa75c1f6244f07971773adb4a26625edd5c18fee906fa84d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/7b/7c2e8a9ee3e6bc0bae26bf29f5219955ca2fbb761dca996a83f5d2f773fe/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bc596b30f86dc6f0929499c9e574601679d0341a0108c25b9b358a042f51bca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/d6/f61cafbed8ba1499b9af9f1777a2a199cd888f74a96133d8833ce5eaa9c5/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9dfbe56b299cf5875b68eb6f0ebaadc9cac520a1989cac0db0765abfb3709c19" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/19/c8ac0a8a8df2dd30cdec27f69298a5c13e9029500d6d76718130f5e5be10/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac64f4b2bdb4ea622175c9ab7cf09444e412e22c0e02e906978b3b488af5fde8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/6b1859898bc292a9ce5776016c7312b672da00e25cec74d7beced1027286/rpds_py-0.26.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:181ef9b6bbf9845a264f9aa45c31836e9f3c1f13be565d0d010e964c661d1e2b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/b9/ceb39af29913c07966a61367b3c08b4f71fad841e32c6b59a129d5974698/rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:49028aa684c144ea502a8e847d23aed5e4c2ef7cadfa7d5eaafcb40864844b7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/27/35637b98380731a521f8ec4f3fd94e477964f04f6b2f8f7af8a2d889a4af/rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5d524d68a474a9688336045bbf76cb0def88549c1b2ad9dbfec1fb7cfbe9170" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/d9/3f0f105420fecd18551b678c9a6ce60bd23986098b252a56d35781b3e7e9/rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1851f429b822831bd2edcbe0cfd12ee9ea77868f8d3daf267b189371671c80e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/c5/347c056a90dc8dd9bc240a08c527315008e1b5042e7a4cf4ac027be9d38a/rpds_py-0.26.0-cp314-cp314t-win32.whl", hash = "sha256:7bdb17009696214c3b66bb3590c6d62e14ac5935e53e929bcdbc5a495987a84f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/04/5302cea1aa26d886d34cadbf2dc77d90d7737e576c0065f357b96dc7a1a6/rpds_py-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f14440b9573a6f76b4ee4770c13f0b5921f71dde3b6fcb8dabbefd13b7fe05d7" }, +] + +[[package]] +name = "ruff" +version = "0.12.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" }, +] + +[[package]] +name = "uv" +version = "0.7.20" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/78/97/1ff10c82d3f0b4246c3a94c09ab4b40d0f7d6dfaafb352175d169ef357e5/uv-0.7.20.tar.gz", hash = "sha256:6adf2ad333e8da133eecbdd2bdb4e8dfb6d4b2db2c3b4739b6705aa347c997ee" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/94/dc/640875ba8ab0b73401cf0cc72c96a568a817a55c71e939f3a93c5911d0ba/uv-0.7.20-py3-none-linux_armv6l.whl", hash = "sha256:9e59b3b0c62255ac87f3fd5b0c58133187983cac57ab86e127cde1b8a2ee32ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/9a/ee440ac67678fad39c087d0494c1e84103cc1ff9bfb88c91b71c7fd5dea3/uv-0.7.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f4c7df0f4dfca809b403fb047ee23b3a35e1221df7be9ade8bbd4fb379f50dc2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/d0/5bcf679907e6d4fb864e4e30b573060734cc1c26afb38b355dac003ce452/uv-0.7.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b8e636777e0ed816461e73ac85445aedb01c3380a61d3f66fa59423582a7456a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/83/0dbe7a1983bb6232ec51afb3bbba11721a31afcc731c56ce898dc91f6541/uv-0.7.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:9b6b95ccc34649c34a05821e3eb8dbc851ee14011e1ddc39b507460b8407a024" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/4f/502c5e0cac26bb36413abc99ab8d4d136f73864c4ec5fe7aee4cc170c5e5/uv-0.7.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d606d70cf79cd7f4bf8b940d331c863b33ac59266fa7dc8da2852187d1494334" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/3c/13ce07214c41790d2fd99c839b1dedfcf2d4c5b6a9696e9822ef9a6014f5/uv-0.7.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:086918380296feea5d49abd82b80a324c2a6401e098db050b8338f6ca7a75e79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/26/5313099e0214087910fb09d14e9acb516db941d2f4fa67a8d983f5295952/uv-0.7.20-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:914dbd8e8a83303f6108cead85e4b83ea748b9cbb8cb03df030c4952b67f40fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/de/cf7fe214e420f8fc1b9eb7a09cca5bb3c05663fec73d6750613c9f68bc26/uv-0.7.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20bfb4a8f42449c0ec7d4b0f1cc91e5a6713e5c55e8ec9b9de9628e21b4db74c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/cc/356c927be05e1dd725b0cc5b0d0d50eda724e2e22f610915b235ad40e559/uv-0.7.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96ad43a3fffbc97b5da90d221788d3fd5c086ec9b1dbdea89a5107a2b5d46fa0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/82/a9f8f31434ae10fc7c81a09cb562d08544a195db48bbf062702bdacbb2c8/uv-0.7.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f25ad1ca8cd756266797a14d153c74ff1d6c7705a63b5036ac5c51c63b6870b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/fd/ea803971d83b3238d62859fc8cd62daa69fa4464eb669e21712bbf91f59e/uv-0.7.20-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d50f2ce3e9d754dfef0b761a3dcc0cc60d045f525894a8b5d76641d9ccd0d257" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/c6/b3e38a77c759888584ee39fd3aebaca8c03fbe890cd1cb1d794cd628605e/uv-0.7.20-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:1d5a095a9ab9b5424cb5f6de75969402a3e0b3d40e04005e3379ebc5f493a582" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/10/422a537d2e983ac55c493d9d9d3fe33ad37784cbac2f4ef6bcb4b5c45200/uv-0.7.20-py3-none-musllinux_1_1_i686.whl", hash = "sha256:baa286b2f847edbb13f3f7baf01ebca73e4dad5b70900270abb20639f03d9770" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/77/696b2795f18ce3cc0bf4a1192564402f8b16c9e1e7b6c7061d1059c52a52/uv-0.7.20-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:67cf45da498955f46208d28c1a5fa58550553defc3f747156247335d65c5b4c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/d3/6d5a2cb1cf0ed48442910c5ed0f1fdc984c66189107b42c85bb53f421332/uv-0.7.20-py3-none-win32.whl", hash = "sha256:246d45e7eb5934ffc23351c4f1d6e7385da21f63929e83d18855d901fd6f5ed4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/6f/9412b857d5c311b57eaf40acdbc612524ac6caac2221303adcebca9a1875/uv-0.7.20-py3-none-win_amd64.whl", hash = "sha256:85bbdd6b40dc6f78c1c60a7b5c3c1dc992acdc7160c99801d1d4a4766dd42a4f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/fb/e23895a4d5980450d26602b1f4887ce67ccc07f21e943f348bd519c6596f/uv-0.7.20-py3-none-win_arm64.whl", hash = "sha256:693ad1f9ecb87f1ddc735682d6d96fcff41a4aa90ae663c57252c7a8e57d4459" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e" }, +] + +[[package]] +name = "xmltodict" +version = "0.14.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/50/05/51dcca9a9bf5e1bce52582683ce50980bcadbc4fa5143b9f2b19ab99958f/xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d6/45/fc303eb433e8a2a271739c98e953728422fa61a3c1f36077a49e395c972e/xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac" }, +] + +[[package]] +name = "xyzservices" +version = "2025.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/00/af/c0f7f97bb320d14c089476f487b81f733238cc5603e0914f2e409f49d589/xyzservices-2025.4.0.tar.gz", hash = "sha256:6fe764713648fac53450fbc61a3c366cb6ae5335a1b2ae0c3796b495de3709d8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d6/7d/b77455d7c7c51255b2992b429107fab811b2e36ceaf76da1e55a045dc568/xyzservices-2025.4.0-py3-none-any.whl", hash = "sha256:8d4db9a59213ccb4ce1cf70210584f30b10795bff47627cdfb862b39ff6e10c9" }, +]