Compare commits
10 Commits
b7872f29a9
...
agent-mvp
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa8e55748 | |||
| 7a9fcaae81 | |||
| a1e9673d9a | |||
| e588d1cf33 | |||
| 1712ecd4c7 | |||
| 441979f581 | |||
| e336ffcd46 | |||
| 52b8f07abd | |||
| 7efaeb41e8 | |||
| 9a7aad2d36 |
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.github
|
||||
.gitea
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
build/
|
||||
dist/
|
||||
package/
|
||||
temp/
|
||||
data/
|
||||
db_inp/
|
||||
inp/
|
||||
.env
|
||||
*.pyc
|
||||
*.dump
|
||||
@@ -48,3 +48,18 @@ METADATA_DB_PASSWORD="password"
|
||||
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
KEYCLOAK_ALGORITHM=RS256
|
||||
KEYCLOAK_AUDIENCE="account"
|
||||
|
||||
|
||||
# ============================================
|
||||
# Bocha Web Search API
|
||||
# ============================================
|
||||
BOCHA_API_KEY="sk-your-bocha-api-key"
|
||||
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
|
||||
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
|
||||
|
||||
# ============================================
|
||||
# Tianditu Geocoding API
|
||||
# ============================================
|
||||
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
|
||||
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
|
||||
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
name: Server CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "latest"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
GIT_USERNAME: ${{ github.actor }}
|
||||
GIT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$SERVER_URL" in
|
||||
http://*)
|
||||
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
|
||||
;;
|
||||
https://*)
|
||||
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
|
||||
;;
|
||||
*)
|
||||
AUTH_SERVER_URL="$SERVER_URL"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d .git ]; then
|
||||
git init .
|
||||
fi
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
else
|
||||
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
fi
|
||||
|
||||
git fetch --depth=1 origin "$COMMIT_SHA"
|
||||
git checkout --force --detach FETCH_HEAD
|
||||
git clean -ffdx
|
||||
|
||||
- name: Normalize image metadata
|
||||
env:
|
||||
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
|
||||
RAW_REPOSITORY: ${{ github.repository }}
|
||||
RAW_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
|
||||
if [ -z "${RAW_REGISTRY_HOST}" ]; then
|
||||
echo "Missing required repository variable: REGISTRY_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
||||
|
||||
if [ -z "${REGISTRY_HOST}" ]; then
|
||||
echo "Repository variable REGISTRY_HOST resolves to an empty host"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
|
||||
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
||||
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
|
||||
IMAGE_TAG="${RAW_REF_NAME}"
|
||||
{
|
||||
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
||||
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
||||
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
|
||||
echo "IMAGE_NAME=${IMAGE_NAME}"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "${REGISTRY_HOST:-}" ]; then
|
||||
echo "Missing resolved environment value: REGISTRY_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${REGISTRY_USERNAME}" ]; then
|
||||
echo "Missing required repository secret: REGISTRY_USERNAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${REGISTRY_PASSWORD}" ]; then
|
||||
echo "Missing required repository secret: REGISTRY_PASSWORD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Logging into registry host: ${REGISTRY_HOST}"
|
||||
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
|
||||
--username "${REGISTRY_USERNAME}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
|
||||
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
push_with_retry() {
|
||||
image_ref="$1"
|
||||
attempt=1
|
||||
max_attempts=3
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if docker push "$image_ref"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${IMAGE_TAG}" = "latest" ]; then
|
||||
docker build \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
else
|
||||
docker build \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
- name: Notify Deploy Server
|
||||
run: |
|
||||
post_deploy_webhook() {
|
||||
label="$1"
|
||||
payload="$2"
|
||||
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
|
||||
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
|
||||
|
||||
webhook_url=$(echo "$webhook_url" | xargs)
|
||||
|
||||
echo "[$label] Calling webhook: $webhook_url"
|
||||
|
||||
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-d "$payload")
|
||||
|
||||
echo "[$label] webhook HTTP status: ${http_code}"
|
||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[$label] response headers:"
|
||||
cat /tmp/deploy_headers.txt
|
||||
echo "[$label] response body:"
|
||||
cat /tmp/deploy_response.txt
|
||||
return 1
|
||||
}
|
||||
|
||||
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
|
||||
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
|
||||
|
||||
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
|
||||
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
|
||||
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Primary webhook request failed, retrying with lowercase repo path..."
|
||||
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
|
||||
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Deploy webhook failed after primary and fallback attempts."
|
||||
exit 1
|
||||
|
||||
deploy-fallback-log:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: docker-image
|
||||
if: failure()
|
||||
steps:
|
||||
- name: Deployment not triggered
|
||||
run: echo "Image build/push failed, deployment webhook was not called."
|
||||
@@ -1,82 +0,0 @@
|
||||
# Copilot Instructions for TJWater Server
|
||||
|
||||
This repository contains the backend code for the TJWater Server, a water distribution network management system built with FastAPI.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The application follows a layered architecture:
|
||||
|
||||
- **Entry Point**: `app/main.py` initializes the FastAPI application, database connections (PostgreSQL & TimescaleDB), and middleware.
|
||||
- **API Layer**: `app/api/v1` contains the route handlers.
|
||||
- **Service Layer**: `app/services` contains business logic and orchestration.
|
||||
- **Infrastructure Layer**: `app/infra` handles database connections (`db`), audit logging (`audit`), and external integrations.
|
||||
- **Domain Layer**: `app/domain` likely contains core domain models.
|
||||
- **Native/Algorithms**: `app/native` and `app/algorithms` handle specialized water network calculations (possibly using EPANET/WNTR).
|
||||
|
||||
## Build, Test, and Run Commands
|
||||
|
||||
### Environment Setup
|
||||
|
||||
- Dependencies are listed in `requirements.txt`.
|
||||
- Configuration is managed via environment variables (see `.env.example` if available, or `app/core/config.py`).
|
||||
- **Important**: Ensure `.env` is configured with correct database credentials for both PostgreSQL and TimescaleDB.
|
||||
|
||||
If first time setting up, you may want to create a Conda environment:
|
||||
|
||||
```bash
|
||||
conda create -n server python=3.12
|
||||
conda activate server
|
||||
pip install uv
|
||||
uv pip install -r requirements.txt
|
||||
conda install -c conda-forge pymetis
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
The preferred way to run the server locally is using the helper script which sets up the Python path correctly:
|
||||
|
||||
```bash
|
||||
conda activate server
|
||||
python scripts/run_server.py
|
||||
```
|
||||
|
||||
Alternatively, you can run directly with uvicorn (ensure PYTHONPATH includes the root):
|
||||
|
||||
```bash
|
||||
conda activate server
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Use `pytest` to run tests. The `tests/conftest.py` handles path setup.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run a specific test file
|
||||
pytest tests/unit/test_specific_file.py
|
||||
|
||||
# Run a specific test case
|
||||
pytest tests/unit/test_specific_file.py::test_function_name
|
||||
```
|
||||
|
||||
### Building (Optional)
|
||||
|
||||
The project includes scripts to compile Python modules to `.pyd` files using Cython (see `scripts/build_pyd.py`). This is likely for distribution/performance but not required for standard development.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Async/Await**: The codebase heavily uses `async` and `await` for I/O operations, especially database interactions.
|
||||
- **Database Management**:
|
||||
- Connections are managed globally in `app.infra.db` and initialized in `lifespan` (app/main.py).
|
||||
- Use `app.infra.db.dynamic_manager` for project-specific database connections (multi-tenancy/dynamic projects).
|
||||
- **Pydantic**: extensively used for data validation and settings management.
|
||||
- **Scripts**: The `scripts/` directory contains many utility scripts for maintenance, data processing, and server management. Check there before writing new operational scripts.
|
||||
- **Water Network Modeling**: Interactions with water network models often involve `epanet` or `wntr` libraries. Be aware of domain-specific terminology (nodes, links, junctions, tanks).
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow standard PEP 8 guidelines.
|
||||
- No specific linter configuration was found, so default to standard Python formatting.
|
||||
@@ -1,128 +0,0 @@
|
||||
name: Build And Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-package:
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install system build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential
|
||||
|
||||
- name: Install compile dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install cython setuptools wheel
|
||||
|
||||
- name: Run Cython compile
|
||||
run: |
|
||||
python scripts/compile.py
|
||||
|
||||
- name: Prepare package and archive
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path.cwd()
|
||||
package_dir = root / "package"
|
||||
dist_dir = root / "dist"
|
||||
|
||||
for d in [package_dir, dist_dir]:
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Define directories with compiled artifacts
|
||||
compile_dirs = ["app/services", "app/native/wndb", "app/algorithms"]
|
||||
# Global ignore list
|
||||
ignore_names = {
|
||||
".git",
|
||||
".github",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".venv",
|
||||
"venv",
|
||||
"temp",
|
||||
"tests",
|
||||
"package",
|
||||
"dist",
|
||||
}
|
||||
|
||||
def ignore_func(directory, names):
|
||||
rel_dir = os.path.relpath(directory, root).replace("\\", "/")
|
||||
is_in_compile_path = any(rel_dir.startswith(d) for d in compile_dirs)
|
||||
|
||||
ignored = []
|
||||
for name in names:
|
||||
if name in ignore_names or name.endswith(".pyc"):
|
||||
ignored.append(name)
|
||||
# Exclude source .py files only in compiled directories
|
||||
elif is_in_compile_path and name.endswith(".py"):
|
||||
ignored.append(name)
|
||||
return ignored
|
||||
|
||||
for item in root.iterdir():
|
||||
if item.name in ignore_names:
|
||||
continue
|
||||
target = package_dir / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, target, ignore=ignore_func)
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
|
||||
# Safety guard: ensure no .github directory remains
|
||||
github_paths = [p for p in package_dir.rglob(".github") if p.is_dir()]
|
||||
for p in github_paths:
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
|
||||
sha = os.environ["GITHUB_SHA"]
|
||||
run_os = os.environ["RUNNER_OS"].lower()
|
||||
|
||||
if run_os == "windows":
|
||||
archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.zip"
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in package_dir.rglob("*"):
|
||||
if f.is_file():
|
||||
zf.write(f, f.relative_to(package_dir))
|
||||
else:
|
||||
archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as tf:
|
||||
tf.add(package_dir, arcname=".")
|
||||
|
||||
print(f"Archive created: {archive_path}")
|
||||
PY
|
||||
shell: bash
|
||||
|
||||
- name: Upload package artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: tjwater-server-package-${{ runner.os }}
|
||||
path: dist/*
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,38 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`.
|
||||
|
||||
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Use the existing conda environment when available:
|
||||
|
||||
```bash
|
||||
conda run -n server python -m pytest tests/unit tests/auth -q
|
||||
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
docker build -t tjwater-server:local .
|
||||
docker compose -f infra/docker/docker-compose.yml config
|
||||
```
|
||||
|
||||
`pytest` runs backend tests. `uvicorn` starts the FastAPI app locally. `docker build` verifies the container image. `docker compose config` validates compose syntax and variable expansion.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use Python 3.12, four-space indentation, type hints for new public functions, and explicit imports. Keep API endpoint modules grouped by domain under `app/api/v1/endpoints`. Use `snake_case` for files, functions, and variables; `PascalCase` for classes and Pydantic models. Prefer existing repository/service patterns in `app/infra/db` and `app/services` over introducing new abstractions.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
The project uses `pytest`. Name test files `test_*.py` and test functions `test_*`. Keep unit tests isolated with fakes or monkeypatching from `tests/conftest.py`. Some existing tests depend on local data outside the repository; avoid adding new tests that require untracked files. For API changes, add or update tests in `tests/api`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
|
||||
|
||||
Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit `.env`, database dumps, generated caches, or local project data. Use `.env.example` as the configuration template. Secrets for CI/CD belong in Gitea repository secrets such as `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and deploy webhook credentials.
|
||||
+8
-5
@@ -2,19 +2,22 @@ FROM condaforge/miniforge3:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \
|
||||
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
|
||||
RUN mamba install -y python=3.12 pymetis && \
|
||||
mamba clean -afy
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install uv
|
||||
RUN pip install --no-cache-dir uv
|
||||
RUN uv pip install --system --no-cache-dir -r requirements.txt
|
||||
|
||||
# 将代码放入子目录 'app',将数据放入子目录 'db_inp'
|
||||
# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离
|
||||
# 将代码放入子目录 'app',临时数据目录运行时创建。
|
||||
# db_inp 和 .env 都不应依赖 Git 跟踪或被烘焙进镜像。
|
||||
COPY app ./app
|
||||
COPY db_inp ./db_inp
|
||||
COPY .env .
|
||||
RUN mkdir -p ./db_inp
|
||||
|
||||
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.services.geocoding import (
|
||||
TiandituGeocodeRequest,
|
||||
TiandituGeocodingAPIError,
|
||||
TiandituGeocodingConfigError,
|
||||
geocode_tianditu,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tianditu/geocode",
|
||||
summary="Tianditu Geocoding",
|
||||
description="调用天地图地理编码服务,将结构化地址转换为经纬度",
|
||||
)
|
||||
async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await geocode_tianditu(request)
|
||||
except TiandituGeocodingConfigError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except TiandituGeocodingAPIError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.services.web_search import (
|
||||
BochaSearchAPIError,
|
||||
BochaSearchConfigError,
|
||||
WebSearchRequest,
|
||||
search_bocha_web,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/web-search",
|
||||
summary="Web Search",
|
||||
description="调用 Bocha Web Search API 获取实时网页搜索结果",
|
||||
)
|
||||
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await search_bocha_web(request)
|
||||
except BochaSearchConfigError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except BochaSearchAPIError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
@@ -18,6 +18,8 @@ from app.api.v1.endpoints import (
|
||||
user_management, # 新增:用户管理
|
||||
audit, # 新增:审计日志
|
||||
meta,
|
||||
web_search,
|
||||
geocoding,
|
||||
)
|
||||
from app.api.v1.endpoints.network import (
|
||||
general,
|
||||
@@ -93,6 +95,8 @@ api_router.include_router(schemes.router, tags=["Schemes"])
|
||||
api_router.include_router(misc.router, tags=["Misc"])
|
||||
api_router.include_router(risk.router, tags=["Risk"])
|
||||
api_router.include_router(cache.router, tags=["Cache"])
|
||||
api_router.include_router(web_search.router, tags=["Web Search"])
|
||||
api_router.include_router(geocoding.router, tags=["Geocoding"])
|
||||
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
|
||||
api_router.include_router(
|
||||
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
|
||||
|
||||
@@ -64,6 +64,16 @@ class Settings(BaseSettings):
|
||||
KEYCLOAK_ALGORITHM: str = "RS256"
|
||||
KEYCLOAK_AUDIENCE: str = ""
|
||||
|
||||
# Bocha Web Search API
|
||||
BOCHA_API_KEY: str = ""
|
||||
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
||||
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
|
||||
|
||||
# Tianditu Geocoding API
|
||||
TIANDITU_GEOCODER_TOKEN: str = ""
|
||||
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
|
||||
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
|
||||
|
||||
@property
|
||||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||||
db_password = quote_plus(self.DB_PASSWORD)
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
from app.services.network_import import network_update, submit_scada_info
|
||||
from app.services.scheme_management import (
|
||||
create_user,
|
||||
delete_user,
|
||||
scheme_name_exists,
|
||||
store_scheme_info,
|
||||
delete_scheme_info,
|
||||
query_scheme_list,
|
||||
upload_shp_to_pg,
|
||||
submit_risk_probability_result,
|
||||
)
|
||||
from app.services.valve_isolation import analyze_valve_isolation
|
||||
from app.services.simulation_ops import (
|
||||
project_management,
|
||||
scheduling_simulation,
|
||||
daily_scheduling_simulation,
|
||||
)
|
||||
from app.services.leakage_identifier import run_leakage_identification
|
||||
"""Service package.
|
||||
|
||||
__all__ = [
|
||||
"network_update",
|
||||
"submit_scada_info",
|
||||
"create_user",
|
||||
"delete_user",
|
||||
"scheme_name_exists",
|
||||
"store_scheme_info",
|
||||
"delete_scheme_info",
|
||||
"query_scheme_list",
|
||||
"upload_shp_to_pg",
|
||||
"submit_risk_probability_result",
|
||||
"project_management",
|
||||
"scheduling_simulation",
|
||||
"daily_scheduling_simulation",
|
||||
"analyze_valve_isolation",
|
||||
"run_leakage_identification",
|
||||
]
|
||||
Keep package initialization lightweight. Import concrete service modules directly,
|
||||
for example: `from app.services.tjnetwork import open_project`.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class TiandituGeocodeRequest(BaseModel):
|
||||
keyword: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
validation_alias=AliasChoices("keyword", "keyWord"),
|
||||
description="地理编码地址关键字",
|
||||
)
|
||||
|
||||
|
||||
class TiandituGeocodingConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class TiandituGeocodingAPIError(RuntimeError):
|
||||
def __init__(self, status_code: int, detail: Any):
|
||||
super().__init__("Tianditu Geocoding API request failed")
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
async def geocode_tianditu(
|
||||
request: TiandituGeocodeRequest,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not settings.TIANDITU_GEOCODER_TOKEN:
|
||||
raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured")
|
||||
|
||||
params = {
|
||||
"ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False),
|
||||
"tk": settings.TIANDITU_GEOCODER_TOKEN,
|
||||
}
|
||||
|
||||
if client is not None:
|
||||
response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params)
|
||||
return _parse_response(response)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.TIANDITU_GEOCODER_TIMEOUT_SECONDS
|
||||
) as managed_client:
|
||||
response = await managed_client.get(
|
||||
settings.TIANDITU_GEOCODER_URL,
|
||||
params=params,
|
||||
)
|
||||
return _parse_response(response)
|
||||
|
||||
|
||||
def _parse_response(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise TiandituGeocodingAPIError(
|
||||
exc.response.status_code,
|
||||
_response_detail(exc.response),
|
||||
) from exc
|
||||
|
||||
data = response.json()
|
||||
if str(data.get("status")) != "0":
|
||||
raise TiandituGeocodingAPIError(502, data)
|
||||
return data
|
||||
|
||||
|
||||
def _response_detail(response: httpx.Response) -> Any:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
@@ -0,0 +1,93 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"]
|
||||
|
||||
|
||||
class WebSearchRequest(BaseModel):
|
||||
query: str = Field(..., min_length=1, description="搜索关键词")
|
||||
freshness: Freshness | str = Field(
|
||||
default="noLimit",
|
||||
description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围",
|
||||
)
|
||||
summary: bool = Field(default=True, description="是否返回网页摘要")
|
||||
count: int = Field(default=10, ge=1, le=50, description="返回结果数量")
|
||||
include: list[str] | None = Field(default=None, description="限定搜索域名")
|
||||
exclude: list[str] | None = Field(default=None, description="排除搜索域名")
|
||||
|
||||
|
||||
class BochaSearchConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BochaSearchAPIError(RuntimeError):
|
||||
def __init__(self, status_code: int, detail: Any):
|
||||
super().__init__("Bocha Web Search API request failed")
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _build_payload(request: WebSearchRequest) -> dict[str, Any]:
|
||||
payload = request.model_dump(exclude_none=True)
|
||||
if request.include:
|
||||
payload["include"] = ",".join(request.include)
|
||||
if request.exclude:
|
||||
payload["exclude"] = ",".join(request.exclude)
|
||||
return payload
|
||||
|
||||
|
||||
async def search_bocha_web(
|
||||
request: WebSearchRequest,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not settings.BOCHA_API_KEY:
|
||||
raise BochaSearchConfigError("BOCHA_API_KEY is not configured")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.BOCHA_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = _build_payload(request)
|
||||
|
||||
if client is not None:
|
||||
response = await client.post(
|
||||
settings.BOCHA_WEB_SEARCH_URL,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
return _parse_response(response)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS
|
||||
) as managed_client:
|
||||
response = await managed_client.post(
|
||||
settings.BOCHA_WEB_SEARCH_URL,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
return _parse_response(response)
|
||||
|
||||
|
||||
def _parse_response(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise BochaSearchAPIError(
|
||||
exc.response.status_code,
|
||||
_response_detail(exc.response),
|
||||
) from exc
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def _response_detail(response: httpx.Response) -> Any:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
@@ -71,14 +71,14 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
observed_runtime_ids.append(id(ctx))
|
||||
assert ctx.auth.access_token == "token-1"
|
||||
assert kwargs["params"] == {"network": "tjwater", "node": "11"}
|
||||
return {"node": "11"}, 5
|
||||
assert kwargs["params"] == {"network": "tjwater", "junction": "11"}
|
||||
return {"id": "11"}, 5
|
||||
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["--auth-stdin", "network", "get-node-properties", "--node", "11"],
|
||||
["--auth-stdin", "network", "get-junction-properties", "--junction", "11"],
|
||||
input=json.dumps(
|
||||
{
|
||||
"server": "http://server",
|
||||
@@ -93,37 +93,70 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"node": "11"}
|
||||
assert payload["data"] == {"id": "11"}
|
||||
assert len(observed_runtime_ids) == 1
|
||||
|
||||
|
||||
def test_network_get_all_junction_properties_uses_network_context(monkeypatch):
|
||||
def test_network_get_junction_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "J1"}], 5
|
||||
return {"id": "J1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-junction-properties"])
|
||||
result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "J1"}]
|
||||
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}}
|
||||
assert payload["data"] == {"id": "J1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getjunctionproperties/",
|
||||
"params": {"network": "tjwater", "junction": "J1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_pipe_properties_uses_network_context(monkeypatch):
|
||||
def test_network_get_pipe_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "P1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "P1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getpipeproperties/",
|
||||
"params": {"network": "tjwater", "pipe": "P1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_pipes_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "P1"}], 5
|
||||
|
||||
@@ -132,13 +165,233 @@ def test_network_get_all_pipe_properties_uses_network_context(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-pipe-properties"])
|
||||
result = runner.invoke(app, ["network", "get-all-pipes-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "P1"}]
|
||||
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getallpipeproperties/",
|
||||
"params": {"network": "tjwater"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_reservoir_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "R1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "R1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getreservoirproperties/",
|
||||
"params": {"network": "tjwater", "reservoir": "R1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "R1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-reservoirs-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "R1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getallreservoirproperties/",
|
||||
"params": {"network": "tjwater"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_tank_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "T1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "T1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/gettankproperties/",
|
||||
"params": {"network": "tjwater", "tank": "T1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_tank_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "T1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-tanks-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "T1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getalltankproperties/",
|
||||
"params": {"network": "tjwater"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_pump_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "PU1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "PU1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getpumpproperties/",
|
||||
"params": {"network": "tjwater", "pump": "PU1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_pump_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "PU1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-pumps-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "PU1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getallpumpproperties/",
|
||||
"params": {"network": "tjwater"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_valve_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "V1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "V1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getvalveproperties/",
|
||||
"params": {"network": "tjwater", "valve": "V1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_valve_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "V1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-valves-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "V1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/getallvalveproperties/",
|
||||
"params": {"network": "tjwater"},
|
||||
}
|
||||
|
||||
|
||||
def test_help_outputs_json_lists_commands():
|
||||
@@ -245,6 +498,33 @@ def test_leaf_help_flag_includes_usage_and_example():
|
||||
assert "DURATION" in result.stdout
|
||||
|
||||
|
||||
def test_realtime_simulation_help_clarifies_type_values():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["data", "timeseries", "realtime", "simulation-by-id-time", "--help"],
|
||||
prog_name="tjwater-cli",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "links/nodes 是子命令" in result.stdout
|
||||
assert "pipe" in result.stdout
|
||||
assert "junction" in result.stdout
|
||||
|
||||
|
||||
def test_realtime_property_help_lists_supported_fields():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["data", "timeseries", "realtime", "simulation-by-time-property", "--help"],
|
||||
prog_name="tjwater-cli",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "flow" in result.stdout
|
||||
assert "pressure" in result.stdout
|
||||
assert "actual_demand" in result.stdout
|
||||
assert "velocity" in result.stdout
|
||||
|
||||
|
||||
def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
@@ -498,6 +778,139 @@ def test_main_missing_option_error_includes_usage_and_next_step(capsys):
|
||||
assert '"tjwater-cli help simulation run"' in stdout
|
||||
|
||||
|
||||
def test_main_invalid_enum_value_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"realtime",
|
||||
"simulation-by-id-time",
|
||||
"--id",
|
||||
"J1",
|
||||
"--type",
|
||||
"links",
|
||||
"--time",
|
||||
"2025-01-02T03:30:00+08:00",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"summary": "参数无效"' in stdout
|
||||
assert '"code": "INVALID_PARAMETER"' in stdout
|
||||
assert "links" in stdout
|
||||
assert "pipe" in stdout
|
||||
assert "junction" in stdout
|
||||
|
||||
|
||||
def test_main_invalid_pipe_property_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"realtime",
|
||||
"simulation-by-time-property",
|
||||
"--type",
|
||||
"pipe",
|
||||
"--time",
|
||||
"2025-01-02T03:30:00+08:00",
|
||||
"--property",
|
||||
"pressure",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_PROPERTY"' in stdout
|
||||
assert "flow" in stdout
|
||||
assert "velocity" in stdout
|
||||
|
||||
|
||||
def test_main_invalid_scada_field_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"scada",
|
||||
"query",
|
||||
"--device-id",
|
||||
"D1",
|
||||
"--start-time",
|
||||
"2025-01-02T03:00:00+08:00",
|
||||
"--end-time",
|
||||
"2025-01-02T04:00:00+08:00",
|
||||
"--field",
|
||||
"flow",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_FIELD"' in stdout
|
||||
assert "monitored_value" in stdout
|
||||
assert "cleaned_value" in stdout
|
||||
|
||||
|
||||
def test_data_scada_get_rejects_removed_kind_before_request(capsys):
|
||||
exit_code = main(["data", "scada", "get", "--kind", "device", "--id", "D1"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_PARAMETER"' in stdout
|
||||
assert "device" in stdout
|
||||
assert "info" in stdout
|
||||
|
||||
|
||||
def test_data_scada_list_help_only_shows_info_kind():
|
||||
result = runner.invoke(app, ["data", "scada", "list", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "info" in result.stdout
|
||||
assert "device" not in result.stdout
|
||||
assert "element" not in result.stdout
|
||||
|
||||
|
||||
def test_data_scada_help_no_longer_lists_schema():
|
||||
result = runner.invoke(app, ["data", "scada", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
commands = {command["command"] for command in payload["commands"]}
|
||||
assert "data scada get" in commands
|
||||
assert "data scada list" in commands
|
||||
assert "data scada schema" not in commands
|
||||
|
||||
|
||||
def test_data_scada_schema_command_is_removed():
|
||||
result = runner.invoke(app, ["data", "scada", "schema", "--kind", "info"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "No such command 'schema'" in result.output
|
||||
|
||||
|
||||
def test_data_help_no_longer_lists_extension_or_misc():
|
||||
result = runner.invoke(app, ["data", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
commands = {command["command"] for command in payload["commands"]}
|
||||
assert "data timeseries" in commands
|
||||
assert "data scada" in commands
|
||||
assert "data scheme" in commands
|
||||
assert "data extension" not in commands
|
||||
assert "data misc" not in commands
|
||||
|
||||
|
||||
def test_removed_data_extension_and_misc_commands_fail():
|
||||
extension_result = runner.invoke(app, ["data", "extension", "list"])
|
||||
misc_result = runner.invoke(app, ["data", "misc", "sensor-placements"])
|
||||
|
||||
assert extension_result.exit_code == 2
|
||||
assert "No such command 'extension'" in extension_result.output
|
||||
assert misc_result.exit_code == 2
|
||||
assert "No such command 'misc'" in misc_result.output
|
||||
|
||||
|
||||
def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
|
||||
exit_code = main(["analysis"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
@@ -26,8 +26,6 @@ data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
|
||||
app.add_typer(network_app, name="network")
|
||||
app.add_typer(component_app, name="component")
|
||||
@@ -50,8 +48,6 @@ data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada")
|
||||
data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite")
|
||||
data_app.add_typer(data_scada_app, name="scada")
|
||||
data_app.add_typer(data_scheme_app, name="scheme")
|
||||
data_app.add_typer(data_extension_app, name="extension")
|
||||
data_app.add_typer(data_misc_app, name="misc")
|
||||
|
||||
GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
|
||||
(network_app, ("network",)),
|
||||
@@ -75,8 +71,6 @@ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
|
||||
(data_timeseries_composite_app, ("data", "timeseries", "composite")),
|
||||
(data_scada_app, ("data", "scada")),
|
||||
(data_scheme_app, ("data", "scheme")),
|
||||
(data_extension_app, ("data", "extension")),
|
||||
(data_misc_app, ("data", "misc")),
|
||||
]
|
||||
|
||||
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
|
||||
|
||||
@@ -31,6 +31,7 @@ from .core import (
|
||||
require_username,
|
||||
resolve_scheme,
|
||||
)
|
||||
from .option_types import DataSource, ValveMode
|
||||
|
||||
|
||||
@simulation_app.command("run")
|
||||
@@ -100,7 +101,7 @@ def analysis_burst(
|
||||
@analysis_app.command("valve")
|
||||
def analysis_valve(
|
||||
ctx: typer.Context,
|
||||
mode: Annotated[str, typer.Option("--mode", help="close|isolation")],
|
||||
mode: Annotated[ValveMode, typer.Option("--mode", help="分析模式,仅支持 close|isolation")],
|
||||
start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None,
|
||||
valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None,
|
||||
element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None,
|
||||
@@ -110,7 +111,7 @@ def analysis_valve(
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
network = require_network(runtime)
|
||||
if mode == "close":
|
||||
if mode == ValveMode.CLOSE:
|
||||
if not start_time or not valve:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
@@ -135,7 +136,7 @@ def analysis_valve(
|
||||
require_network_ctx=True,
|
||||
)
|
||||
return
|
||||
if mode == "isolation":
|
||||
if mode == ValveMode.ISOLATION:
|
||||
if not element:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
@@ -156,12 +157,7 @@ def analysis_valve(
|
||||
require_network_ctx=True,
|
||||
)
|
||||
return
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_MODE",
|
||||
message="--mode must be close or isolation",
|
||||
exit_code=2,
|
||||
)
|
||||
raise AssertionError(f"unreachable valve mode: {mode}")
|
||||
|
||||
|
||||
@analysis_app.command("flushing")
|
||||
@@ -397,7 +393,7 @@ def analysis_burst_location_locate(
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
||||
burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
data_source: Annotated[str, typer.Option("--data-source", help="monitoring|simulation")] = "monitoring",
|
||||
data_source: Annotated[DataSource, typer.Option("--data-source", help="数据来源,仅支持 monitoring|simulation")] = DataSource.MONITORING,
|
||||
pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None,
|
||||
flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None,
|
||||
pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None,
|
||||
@@ -410,7 +406,7 @@ def analysis_burst_location_locate(
|
||||
body = {
|
||||
"network": require_network(runtime),
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"data_source": data_source,
|
||||
"data_source": data_source.value,
|
||||
"scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
"burst_leakage": burst_leakage,
|
||||
|
||||
@@ -5,8 +5,6 @@ from typing import Annotated
|
||||
import typer
|
||||
|
||||
from .apps import (
|
||||
data_extension_app,
|
||||
data_misc_app,
|
||||
data_scada_app,
|
||||
data_scheme_app,
|
||||
data_timeseries_composite_app,
|
||||
@@ -16,12 +14,55 @@ from .apps import (
|
||||
)
|
||||
from .common import emit_api, runtime_context
|
||||
from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme
|
||||
from .option_types import (
|
||||
CompositeKind,
|
||||
ElementType,
|
||||
JUNCTION_TIMESERIES_FIELDS,
|
||||
SCADA_TIMESERIES_FIELDS,
|
||||
ScadaListKind,
|
||||
SimulationQuery,
|
||||
timeseries_fields_for_element_type,
|
||||
)
|
||||
|
||||
|
||||
def _scheme_type_option(scheme_type: str | None) -> str:
|
||||
return scheme_type or "simulation"
|
||||
|
||||
|
||||
def _validate_element_property(element_type: ElementType, property_name: str, *, option_name: str) -> str:
|
||||
valid_fields = timeseries_fields_for_element_type(element_type)
|
||||
if property_name not in valid_fields:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_PROPERTY",
|
||||
message=f"{option_name} for --type {element_type.value} must be one of: {', '.join(valid_fields)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return property_name
|
||||
|
||||
|
||||
def _validate_node_field(field_name: str, *, option_name: str) -> str:
|
||||
if field_name not in JUNCTION_TIMESERIES_FIELDS:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_FIELD",
|
||||
message=f"{option_name} must be one of: {', '.join(JUNCTION_TIMESERIES_FIELDS)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return field_name
|
||||
|
||||
|
||||
def _validate_scada_field(field_name: str, *, option_name: str) -> str:
|
||||
if field_name not in SCADA_TIMESERIES_FIELDS:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_FIELD",
|
||||
message=f"{option_name} must be one of: {', '.join(SCADA_TIMESERIES_FIELDS)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return field_name
|
||||
|
||||
|
||||
@data_timeseries_realtime_app.command("links")
|
||||
def data_realtime_links(
|
||||
ctx: typer.Context,
|
||||
@@ -66,7 +107,7 @@ def data_realtime_nodes(
|
||||
def data_realtime_simulation_by_id_time(
|
||||
ctx: typer.Context,
|
||||
id: Annotated[str, typer.Option("--id", help="元素 ID")],
|
||||
type: Annotated[str, typer.Option("--type", help="pipe|junction")],
|
||||
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")],
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
@@ -76,7 +117,7 @@ def data_realtime_simulation_by_id_time(
|
||||
path="/realtime/query/by-id-time",
|
||||
params={
|
||||
"id": id,
|
||||
"type": type,
|
||||
"type": type.value,
|
||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
@@ -87,17 +128,18 @@ def data_realtime_simulation_by_id_time(
|
||||
@data_timeseries_realtime_app.command("simulation-by-time-property")
|
||||
def data_realtime_simulation_by_time_property(
|
||||
ctx: typer.Context,
|
||||
type: Annotated[str, typer.Option("--type", help="pipe|junction")],
|
||||
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")],
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||
property: Annotated[str, typer.Option("--property", help="属性名")],
|
||||
property: Annotated[str, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")],
|
||||
) -> None:
|
||||
property = _validate_element_property(type, property, option_name="--property")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取实时属性聚合数据成功",
|
||||
method="GET",
|
||||
path="/realtime/query/by-time-property",
|
||||
params={
|
||||
"type": type,
|
||||
"type": type.value,
|
||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||
"property": property,
|
||||
},
|
||||
@@ -135,13 +177,14 @@ def data_scheme_links(
|
||||
def data_scheme_node_field(
|
||||
ctx: typer.Context,
|
||||
node: Annotated[str, typer.Option("--node", help="节点 ID")],
|
||||
field: Annotated[str, typer.Option("--field", help="字段名")],
|
||||
field: Annotated[str, typer.Option("--field", help="字段名,仅支持 actual_demand|total_head|pressure|quality")],
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
field = _validate_node_field(field, option_name="--field")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案节点字段成功",
|
||||
@@ -162,22 +205,22 @@ def data_scheme_node_field(
|
||||
@data_timeseries_scheme_app.command("simulation")
|
||||
def data_scheme_simulation(
|
||||
ctx: typer.Context,
|
||||
query: Annotated[str, typer.Option("--query", help="by-id-time|by-scheme-time-property")],
|
||||
query: Annotated[SimulationQuery, typer.Option("--query", help="查询模式,仅支持 by-id-time|by-scheme-time-property")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||
id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None,
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")] = "",
|
||||
type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe",
|
||||
property: Annotated[str | None, typer.Option("--property", help="属性名")] = None,
|
||||
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")] = ElementType.PIPE,
|
||||
property: Annotated[str | None, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
params = {
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"scheme_type": _scheme_type_option(scheme_type),
|
||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||
"type": type,
|
||||
"type": type.value,
|
||||
}
|
||||
if query == "by-id-time":
|
||||
if query == SimulationQuery.BY_ID_TIME:
|
||||
if not id:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
@@ -196,7 +239,7 @@ def data_scheme_simulation(
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if query == "by-scheme-time-property":
|
||||
if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY:
|
||||
if not property:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
@@ -204,6 +247,7 @@ def data_scheme_simulation(
|
||||
message="--property is required for --query by-scheme-time-property",
|
||||
exit_code=2,
|
||||
)
|
||||
property = _validate_element_property(type, property, option_name="--property")
|
||||
params["property"] = property
|
||||
emit_api(
|
||||
ctx,
|
||||
@@ -215,12 +259,7 @@ def data_scheme_simulation(
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_QUERY",
|
||||
message="--query must be by-id-time or by-scheme-time-property",
|
||||
exit_code=2,
|
||||
)
|
||||
raise AssertionError(f"unreachable query variant: {query}")
|
||||
|
||||
|
||||
@data_timeseries_scada_app.command("query")
|
||||
@@ -229,7 +268,7 @@ def data_scada_query(
|
||||
device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")],
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
field: Annotated[str | None, typer.Option("--field", help="字段名")] = None,
|
||||
field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None,
|
||||
) -> None:
|
||||
path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range"
|
||||
params = {
|
||||
@@ -238,6 +277,7 @@ def data_scada_query(
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
}
|
||||
if field:
|
||||
field = _validate_scada_field(field, option_name="--field")
|
||||
params["field"] = field
|
||||
emit_api(
|
||||
ctx,
|
||||
@@ -253,7 +293,7 @@ def data_scada_query(
|
||||
@data_timeseries_composite_app.callback(invoke_without_command=True)
|
||||
def data_timeseries_composite(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[str | None, typer.Option("--kind", help="scada-simulation|element-simulation|element-scada")] = None,
|
||||
kind: Annotated[CompositeKind | None, typer.Option("--kind", help="复合查询类型,仅支持 scada-simulation|element-simulation|element-scada")] = None,
|
||||
feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None,
|
||||
start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None,
|
||||
end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None,
|
||||
@@ -277,7 +317,7 @@ def data_timeseries_composite(
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
}
|
||||
if kind == "scada-simulation":
|
||||
if kind == CompositeKind.SCADA_SIMULATION:
|
||||
if not feature:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
@@ -300,7 +340,7 @@ def data_timeseries_composite(
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if kind == "element-simulation":
|
||||
if kind == CompositeKind.ELEMENT_SIMULATION:
|
||||
if not feature:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
@@ -323,7 +363,7 @@ def data_timeseries_composite(
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if kind == "element-scada":
|
||||
if kind == CompositeKind.ELEMENT_SCADA:
|
||||
if not feature or len(feature) != 1:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
@@ -343,12 +383,7 @@ def data_timeseries_composite(
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_KIND",
|
||||
message="--kind must be scada-simulation, element-simulation, or element-scada",
|
||||
exit_code=2,
|
||||
)
|
||||
raise AssertionError(f"unreachable composite kind: {kind}")
|
||||
|
||||
|
||||
@data_timeseries_composite_app.command("pipeline-health")
|
||||
@@ -376,15 +411,6 @@ def data_composite_pipeline_health(
|
||||
|
||||
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
|
||||
mapping = {
|
||||
("device", "schema"): ("/getscadadeviceschema/", {}),
|
||||
("device", "get"): ("/getscadadevice/", {"id_param": "id"}),
|
||||
("device", "list"): ("/getallscadadevices/", {}),
|
||||
("device-data", "schema"): ("/getscadadevicedataschema/", {}),
|
||||
("device-data", "get"): ("/getscadadevicedata/", {"id_param": "device_id"}),
|
||||
("element", "schema"): ("/getscadaelementschema/", {}),
|
||||
("element", "get"): ("/getscadaelement/", {"id_param": "id"}),
|
||||
("element", "list"): ("/getscadaelements/", {}),
|
||||
("info", "schema"): ("/getscadainfoschema/", {}),
|
||||
("info", "get"): ("/getscadainfo/", {"id_param": "id"}),
|
||||
("info", "list"): ("/getallscadainfo/", {}),
|
||||
}
|
||||
@@ -399,32 +425,14 @@ def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
|
||||
return result
|
||||
|
||||
|
||||
@data_scada_app.command("schema")
|
||||
def data_scada_schema(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path, _ = _scada_mapping(kind, "schema")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取 SCADA schema 成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@data_scada_app.command("get")
|
||||
def data_scada_get(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")],
|
||||
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
|
||||
id: Annotated[str, typer.Option("--id", help="记录 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path, meta = _scada_mapping(kind, "get")
|
||||
path, meta = _scada_mapping(kind.value, "get")
|
||||
params = {"network": require_network(runtime), meta["id_param"]: id}
|
||||
emit_api(
|
||||
ctx,
|
||||
@@ -440,10 +448,10 @@ def data_scada_get(
|
||||
@data_scada_app.command("list")
|
||||
def data_scada_list(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[str, typer.Option("--kind", help="device|element|info")],
|
||||
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path, _ = _scada_mapping(kind, "list")
|
||||
path, _ = _scada_mapping(kind.value, "list")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取 SCADA 列表成功",
|
||||
@@ -498,76 +506,3 @@ def data_scheme_list(ctx: typer.Context) -> None:
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@data_extension_app.command("keys")
|
||||
def data_extension_keys(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取扩展数据键成功",
|
||||
method="GET",
|
||||
path="/getallextensiondatakeys/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@data_extension_app.command("get")
|
||||
def data_extension_get(
|
||||
ctx: typer.Context,
|
||||
key: Annotated[str, typer.Option("--key", help="扩展键")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取扩展数据成功",
|
||||
method="GET",
|
||||
path="/getextensiondata/",
|
||||
params={"network": require_network(runtime), "key": key},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@data_extension_app.command("list")
|
||||
def data_extension_list(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取扩展数据列表成功",
|
||||
method="GET",
|
||||
path="/getallextensiondata/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@data_misc_app.command("sensor-placements")
|
||||
def data_misc_sensor_placements(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取传感器位置成功",
|
||||
method="GET",
|
||||
path="/getallsensorplacements/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@data_misc_app.command("burst-location-results")
|
||||
def data_misc_burst_location_results(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取爆管定位结果成功",
|
||||
method="GET",
|
||||
path="/getallburstlocateresults/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
@@ -7,58 +7,45 @@ import typer
|
||||
from .apps import component_option_app, network_app
|
||||
from .common import emit_api, runtime_context
|
||||
from .core import CLIError, require_network
|
||||
from .option_types import ComponentOptionKind
|
||||
|
||||
|
||||
@network_app.command("get-node-properties")
|
||||
def network_get_node_properties(
|
||||
@network_app.command("get-junction-properties")
|
||||
def network_get_junction_properties(
|
||||
ctx: typer.Context,
|
||||
node: Annotated[str, typer.Option("--node", help="节点 ID")],
|
||||
junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取节点属性成功",
|
||||
method="GET",
|
||||
path="/getnodeproperties/",
|
||||
params={"network": require_network(runtime), "node": node},
|
||||
path="/getjunctionproperties/",
|
||||
params={"network": require_network(runtime), "junction": junction},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-link-properties")
|
||||
def network_get_link_properties(
|
||||
@network_app.command("get-pipe-properties")
|
||||
def network_get_pipe_properties(
|
||||
ctx: typer.Context,
|
||||
link: Annotated[str, typer.Option("--link", help="管线 ID")],
|
||||
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取管线属性成功",
|
||||
summary="读取管道属性成功",
|
||||
method="GET",
|
||||
path="/getlinkproperties/",
|
||||
params={"network": require_network(runtime), "link": link},
|
||||
path="/getpipeproperties/",
|
||||
params={"network": require_network(runtime), "pipe": pipe},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-junction-properties")
|
||||
def network_get_all_junction_properties(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部节点属性成功",
|
||||
method="GET",
|
||||
path="/getalljunctionproperties/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-pipe-properties")
|
||||
def network_get_all_pipe_properties(ctx: typer.Context) -> None:
|
||||
@network_app.command("get-all-pipes-properties")
|
||||
def network_get_all_pipes_properties(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
@@ -71,16 +58,140 @@ def network_get_all_pipe_properties(ctx: typer.Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-reservoir-properties")
|
||||
def network_get_reservoir_properties(
|
||||
ctx: typer.Context,
|
||||
reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水库属性成功",
|
||||
method="GET",
|
||||
path="/getreservoirproperties/",
|
||||
params={"network": require_network(runtime), "reservoir": reservoir},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-reservoirs-properties")
|
||||
def network_get_all_reservoir_properties(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水库属性成功",
|
||||
method="GET",
|
||||
path="/getallreservoirproperties/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-tank-properties")
|
||||
def network_get_tank_properties(
|
||||
ctx: typer.Context,
|
||||
tank: Annotated[str, typer.Option("--tank", help="水箱 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水箱属性成功",
|
||||
method="GET",
|
||||
path="/gettankproperties/",
|
||||
params={"network": require_network(runtime), "tank": tank},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-tanks-properties")
|
||||
def network_get_all_tank_properties(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水箱属性成功",
|
||||
method="GET",
|
||||
path="/getalltankproperties/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-pump-properties")
|
||||
def network_get_pump_properties(
|
||||
ctx: typer.Context,
|
||||
pump: Annotated[str, typer.Option("--pump", help="水泵 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水泵属性成功",
|
||||
method="GET",
|
||||
path="/getpumpproperties/",
|
||||
params={"network": require_network(runtime), "pump": pump},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-pumps-properties")
|
||||
def network_get_all_pump_properties(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水泵属性成功",
|
||||
method="GET",
|
||||
path="/getallpumpproperties/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-valve-properties")
|
||||
def network_get_valve_properties(
|
||||
ctx: typer.Context,
|
||||
valve: Annotated[str, typer.Option("--valve", help="阀门 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取阀门属性成功",
|
||||
method="GET",
|
||||
path="/getvalveproperties/",
|
||||
params={"network": require_network(runtime), "valve": valve},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-valves-properties")
|
||||
def network_get_all_valve_properties(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部阀门属性成功",
|
||||
method="GET",
|
||||
path="/getallvalveproperties/",
|
||||
params={"network": require_network(runtime)},
|
||||
require_auth=True,
|
||||
require_network_ctx=True,
|
||||
)
|
||||
|
||||
|
||||
@component_option_app.command("schema")
|
||||
def component_option_schema(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")],
|
||||
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
|
||||
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path = _component_option_path(kind, schema=True)
|
||||
path = _component_option_path(kind.value, schema=True)
|
||||
params = {"network": require_network(runtime)}
|
||||
if kind == "pump-energy" and pump:
|
||||
if kind == ComponentOptionKind.PUMP_ENERGY and pump:
|
||||
params["pump"] = pump
|
||||
emit_api(
|
||||
ctx,
|
||||
@@ -96,13 +207,13 @@ def component_option_schema(
|
||||
@component_option_app.command("get")
|
||||
def component_option_get(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")],
|
||||
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
|
||||
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path = _component_option_path(kind, schema=False)
|
||||
path = _component_option_path(kind.value, schema=False)
|
||||
params = {"network": require_network(runtime)}
|
||||
if kind == "pump-energy":
|
||||
if kind == ComponentOptionKind.PUMP_ENERGY:
|
||||
if not pump:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
|
||||
@@ -15,7 +15,7 @@ import typer
|
||||
|
||||
SCHEMA_VERSION = "tjwater-cli/v1"
|
||||
CLI_NAME = "tjwater-cli"
|
||||
DEFAULT_TIMEOUT = 60
|
||||
DEFAULT_TIMEOUT = 180
|
||||
DEFAULT_SERVER = "http://192.168.1.114:8000"
|
||||
|
||||
|
||||
|
||||
@@ -100,9 +100,8 @@ def _sample_option_value(path: tuple[str, ...], option_name: str) -> str:
|
||||
(("component", "option", "schema"), "kind"): "time",
|
||||
(("component", "option", "get"), "kind"): "time",
|
||||
(("data", "timeseries", "composite"), "kind"): "scada-simulation",
|
||||
(("data", "scada", "schema"), "kind"): "device",
|
||||
(("data", "scada", "get"), "kind"): "device",
|
||||
(("data", "scada", "list"), "kind"): "device",
|
||||
(("data", "scada", "get"), "kind"): "info",
|
||||
(("data", "scada", "list"), "kind"): "info",
|
||||
}
|
||||
if (path, option_name) in path_specific_samples:
|
||||
return path_specific_samples[(path, option_name)]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ElementType(str, Enum):
|
||||
PIPE = "pipe"
|
||||
JUNCTION = "junction"
|
||||
|
||||
|
||||
class SimulationQuery(str, Enum):
|
||||
BY_ID_TIME = "by-id-time"
|
||||
BY_SCHEME_TIME_PROPERTY = "by-scheme-time-property"
|
||||
|
||||
|
||||
class CompositeKind(str, Enum):
|
||||
SCADA_SIMULATION = "scada-simulation"
|
||||
ELEMENT_SIMULATION = "element-simulation"
|
||||
ELEMENT_SCADA = "element-scada"
|
||||
|
||||
|
||||
class ComponentOptionKind(str, Enum):
|
||||
TIME = "time"
|
||||
ENERGY = "energy"
|
||||
PUMP_ENERGY = "pump-energy"
|
||||
NETWORK = "network"
|
||||
|
||||
|
||||
class ValveMode(str, Enum):
|
||||
CLOSE = "close"
|
||||
ISOLATION = "isolation"
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
MONITORING = "monitoring"
|
||||
SIMULATION = "simulation"
|
||||
|
||||
|
||||
class ScadaListKind(str, Enum):
|
||||
INFO = "info"
|
||||
|
||||
|
||||
PIPE_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
)
|
||||
|
||||
JUNCTION_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"actual_demand",
|
||||
"total_head",
|
||||
"pressure",
|
||||
"quality",
|
||||
)
|
||||
|
||||
SCADA_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"monitored_value",
|
||||
"cleaned_value",
|
||||
)
|
||||
|
||||
|
||||
def timeseries_fields_for_element_type(element_type: ElementType) -> tuple[str, ...]:
|
||||
if element_type == ElementType.PIPE:
|
||||
return PIPE_TIMESERIES_FIELDS
|
||||
if element_type == ElementType.JUNCTION:
|
||||
return JUNCTION_TIMESERIES_FIELDS
|
||||
raise AssertionError(f"unreachable element type: {element_type}")
|
||||
+83
-89
@@ -16,7 +16,7 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
|
||||
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
|
||||
("analysis", "risk"): "风险分析相关命令。",
|
||||
("analysis", "sensor-placement"): "传感器选址相关命令。",
|
||||
("data",): "时序、SCADA、方案和扩展数据查询命令。",
|
||||
("data",): "时序、SCADA 和方案数据查询命令。",
|
||||
("data", "timeseries"): "时序数据查询命令。",
|
||||
("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
|
||||
("data", "timeseries", "scheme"): "方案时序查询命令。",
|
||||
@@ -24,8 +24,6 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
|
||||
("data", "timeseries", "composite"): "复合时序查询命令。",
|
||||
("data", "scada"): "SCADA 元数据查询命令。",
|
||||
("data", "scheme"): "方案数据查询命令。",
|
||||
("data", "extension"): "扩展数据查询命令。",
|
||||
("data", "misc"): "其他结果数据查询命令。",
|
||||
}
|
||||
|
||||
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||
@@ -34,31 +32,77 @@ HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||
)
|
||||
|
||||
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("network", "get-node-properties"): CommandDoc(
|
||||
path=("network", "get-node-properties"),
|
||||
("network", "get-junction-properties"): CommandDoc(
|
||||
path=("network", "get-junction-properties"),
|
||||
summary="读取节点属性",
|
||||
description="调用 /getnodeproperties/。",
|
||||
options=(CommandOptionDoc("node", "节点 ID", required=True),),
|
||||
examples=("tjwater-cli network get-node-properties --node J1",),
|
||||
description="调用 /getjunctionproperties/。",
|
||||
options=(CommandOptionDoc("junction", "节点 ID", required=True),),
|
||||
examples=("tjwater-cli network get-junction-properties --junction J1",),
|
||||
),
|
||||
("network", "get-link-properties"): CommandDoc(
|
||||
path=("network", "get-link-properties"),
|
||||
summary="读取管线属性",
|
||||
description="调用 /getlinkproperties/。",
|
||||
options=(CommandOptionDoc("link", "管线 ID", required=True),),
|
||||
examples=("tjwater-cli network get-link-properties --link P1",),
|
||||
("network", "get-pipe-properties"): CommandDoc(
|
||||
path=("network", "get-pipe-properties"),
|
||||
summary="读取管道属性",
|
||||
description="调用 /getpipeproperties/。",
|
||||
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||
examples=("tjwater-cli network get-pipe-properties --pipe P1",),
|
||||
),
|
||||
("network", "get-all-junction-properties"): CommandDoc(
|
||||
path=("network", "get-all-junction-properties"),
|
||||
summary="读取全部节点属性",
|
||||
description="调用 /getalljunctionproperties/。",
|
||||
examples=("tjwater-cli network get-all-junction-properties",),
|
||||
),
|
||||
("network", "get-all-pipe-properties"): CommandDoc(
|
||||
path=("network", "get-all-pipe-properties"),
|
||||
("network", "get-all-pipes-properties"): CommandDoc(
|
||||
path=("network", "get-all-pipes-properties"),
|
||||
summary="读取全部管道属性",
|
||||
description="调用 /getallpipeproperties/。",
|
||||
examples=("tjwater-cli network get-all-pipe-properties",),
|
||||
examples=("tjwater-cli network get-all-pipes-properties",),
|
||||
),
|
||||
("network", "get-reservoir-properties"): CommandDoc(
|
||||
path=("network", "get-reservoir-properties"),
|
||||
summary="读取水库属性",
|
||||
description="调用 /getreservoirproperties/。",
|
||||
options=(CommandOptionDoc("reservoir", "水库 ID", required=True),),
|
||||
examples=("tjwater-cli network get-reservoir-properties --reservoir R1",),
|
||||
),
|
||||
("network", "get-all-reservoirs-properties"): CommandDoc(
|
||||
path=("network", "get-all-reservoirs-properties"),
|
||||
summary="读取全部水库属性",
|
||||
description="调用 /getallreservoirproperties/。",
|
||||
examples=("tjwater-cli network get-all-reservoirs-properties",),
|
||||
),
|
||||
("network", "get-tank-properties"): CommandDoc(
|
||||
path=("network", "get-tank-properties"),
|
||||
summary="读取水箱属性",
|
||||
description="调用 /gettankproperties/。",
|
||||
options=(CommandOptionDoc("tank", "水箱 ID", required=True),),
|
||||
examples=("tjwater-cli network get-tank-properties --tank T1",),
|
||||
),
|
||||
("network", "get-all-tanks-properties"): CommandDoc(
|
||||
path=("network", "get-all-tanks-properties"),
|
||||
summary="读取全部水箱属性",
|
||||
description="调用 /getalltankproperties/。",
|
||||
examples=("tjwater-cli network get-all-tanks-properties",),
|
||||
),
|
||||
("network", "get-pump-properties"): CommandDoc(
|
||||
path=("network", "get-pump-properties"),
|
||||
summary="读取水泵属性",
|
||||
description="调用 /getpumpproperties/。",
|
||||
options=(CommandOptionDoc("pump", "水泵 ID", required=True),),
|
||||
examples=("tjwater-cli network get-pump-properties --pump PU1",),
|
||||
),
|
||||
("network", "get-all-pumps-properties"): CommandDoc(
|
||||
path=("network", "get-all-pumps-properties"),
|
||||
summary="读取全部水泵属性",
|
||||
description="调用 /getallpumpproperties/。",
|
||||
examples=("tjwater-cli network get-all-pumps-properties",),
|
||||
),
|
||||
("network", "get-valve-properties"): CommandDoc(
|
||||
path=("network", "get-valve-properties"),
|
||||
summary="读取阀门属性",
|
||||
description="调用 /getvalveproperties/。",
|
||||
options=(CommandOptionDoc("valve", "阀门 ID", required=True),),
|
||||
examples=("tjwater-cli network get-valve-properties --valve V1",),
|
||||
),
|
||||
("network", "get-all-valves-properties"): CommandDoc(
|
||||
path=("network", "get-all-valves-properties"),
|
||||
summary="读取全部阀门属性",
|
||||
description="调用 /getallvalveproperties/。",
|
||||
examples=("tjwater-cli network get-all-valves-properties",),
|
||||
),
|
||||
("component", "option", "schema"): CommandDoc(
|
||||
path=("component", "option", "schema"),
|
||||
@@ -314,7 +358,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
description="调用 /realtime/query/by-id-time。",
|
||||
options=(
|
||||
CommandOptionDoc("id", "元素 ID", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
),
|
||||
examples=(
|
||||
@@ -325,11 +369,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
|
||||
summary="按时间和属性查询实时模拟结果",
|
||||
description="调用 /realtime/query/by-time-property。",
|
||||
description="调用 /realtime/query/by-time-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
CommandOptionDoc("property", "属性名", required=True),
|
||||
CommandOptionDoc("property", "属性名;会按 type 校验可选值", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",),
|
||||
),
|
||||
@@ -348,10 +392,10 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("data", "timeseries", "scheme", "node-field"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "node-field"),
|
||||
summary="查询方案节点字段时序",
|
||||
description="调用 /scheme/nodes/{node_id}/field。",
|
||||
description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("node", "节点 ID", required=True),
|
||||
CommandOptionDoc("field", "字段名", required=True),
|
||||
CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
@@ -362,15 +406,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("data", "timeseries", "scheme", "simulation"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "simulation"),
|
||||
summary="查询方案模拟数据",
|
||||
description="支持 by-id-time 与 by-scheme-time-property 两种查询。",
|
||||
description="支持 by-id-time 与 by-scheme-time-property 两种查询。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction"),
|
||||
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需)"),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值"),
|
||||
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值)"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme",
|
||||
@@ -380,16 +424,16 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("data", "timeseries", "scada", "query"): CommandDoc(
|
||||
path=("data", "timeseries", "scada", "query"),
|
||||
summary="查询 SCADA 时序",
|
||||
description="device-id 会被转换成后端逗号分隔参数。",
|
||||
description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。",
|
||||
options=(
|
||||
CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("field", "字段名"),
|
||||
CommandOptionDoc("field", "字段名:monitored_value、cleaned_value"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",
|
||||
"tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field flow",
|
||||
"tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "composite"): CommandDoc(
|
||||
@@ -422,41 +466,22 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
),
|
||||
examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||
),
|
||||
("data", "scada", "schema"): CommandDoc(
|
||||
path=("data", "scada", "schema"),
|
||||
summary="读取 SCADA schema",
|
||||
description="kind 支持 device、device-data、element、info。",
|
||||
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
|
||||
examples=(
|
||||
"tjwater-cli data scada schema --kind device",
|
||||
"tjwater-cli data scada schema --kind device-data",
|
||||
"tjwater-cli data scada schema --kind element",
|
||||
"tjwater-cli data scada schema --kind info",
|
||||
),
|
||||
),
|
||||
("data", "scada", "get"): CommandDoc(
|
||||
path=("data", "scada", "get"),
|
||||
summary="读取单条 SCADA 元数据",
|
||||
description="kind 支持 device、device-data、element、info。",
|
||||
description="kind 仅支持 info。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "SCADA 数据类型", required=True),
|
||||
CommandOptionDoc("id", "记录 ID", required=True),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data scada get --kind device --id D1",
|
||||
"tjwater-cli data scada get --kind element --id E1",
|
||||
),
|
||||
examples=("tjwater-cli data scada get --kind info --id SCADA-001",),
|
||||
),
|
||||
("data", "scada", "list"): CommandDoc(
|
||||
path=("data", "scada", "list"),
|
||||
summary="列出 SCADA 元数据",
|
||||
description="kind 支持 device、element、info;device-data 当前后端无 list 接口。",
|
||||
description="kind 仅支持 info。",
|
||||
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
|
||||
examples=(
|
||||
"tjwater-cli data scada list --kind device",
|
||||
"tjwater-cli data scada list --kind element",
|
||||
"tjwater-cli data scada list --kind info",
|
||||
),
|
||||
examples=("tjwater-cli data scada list --kind info",),
|
||||
),
|
||||
("data", "scheme", "schema"): CommandDoc(
|
||||
path=("data", "scheme", "schema"),
|
||||
@@ -477,37 +502,6 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
description="调用 /getallschemes/。",
|
||||
examples=("tjwater-cli data scheme list",),
|
||||
),
|
||||
("data", "extension", "keys"): CommandDoc(
|
||||
path=("data", "extension", "keys"),
|
||||
summary="列出扩展数据键",
|
||||
description="调用 /getallextensiondatakeys/。",
|
||||
examples=("tjwater-cli data extension keys",),
|
||||
),
|
||||
("data", "extension", "get"): CommandDoc(
|
||||
path=("data", "extension", "get"),
|
||||
summary="读取扩展数据",
|
||||
description="调用 /getextensiondata/。",
|
||||
options=(CommandOptionDoc("key", "扩展键", required=True),),
|
||||
examples=("tjwater-cli data extension get --key my_key",),
|
||||
),
|
||||
("data", "extension", "list"): CommandDoc(
|
||||
path=("data", "extension", "list"),
|
||||
summary="列出扩展数据",
|
||||
description="调用 /getallextensiondata/。",
|
||||
examples=("tjwater-cli data extension list",),
|
||||
),
|
||||
("data", "misc", "sensor-placements"): CommandDoc(
|
||||
path=("data", "misc", "sensor-placements"),
|
||||
summary="列出传感器布置结果",
|
||||
description="调用 /getallsensorplacements/。",
|
||||
examples=("tjwater-cli data misc sensor-placements",),
|
||||
),
|
||||
("data", "misc", "burst-location-results"): CommandDoc(
|
||||
path=("data", "misc", "burst-location-results"),
|
||||
summary="列出爆管定位结果",
|
||||
description="调用 /getallburstlocateresults/。",
|
||||
examples=("tjwater-cli data misc burst-location-results",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -259,12 +259,8 @@ app/api/v1/endpoints/project_data.py
|
||||
| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 |
|
||||
| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 |
|
||||
| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
|
||||
| `tjwater-cli data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` |
|
||||
| `tjwater-cli data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 |
|
||||
| `tjwater-cli data scada get\|list --kind info` | `GET /getscadainfo/`、`GET /getallscadainfo/` | `SCADA info` 元数据 |
|
||||
| `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 |
|
||||
| `tjwater-cli data extension keys\|get\|list` | `extension.py` 下 `GET` 查询接口 | 当前 project 扩展数据查询 |
|
||||
| `tjwater-cli data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 |
|
||||
| `tjwater-cli data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 |
|
||||
|
||||
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
|
||||
- `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
|
||||
|
||||
@@ -3,9 +3,10 @@ services:
|
||||
# Core API Service
|
||||
# ==========================================
|
||||
api:
|
||||
image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
container_name: tjwater_api
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -168,3 +168,4 @@ zmq==0.0.0
|
||||
pymoo==0.6.1.6
|
||||
scikit-learn==1.6.1
|
||||
scipy==1.15.2
|
||||
pyclipper==1.4.0
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh origin latest"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh origin v2026.06.09.1"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
resolve_default_remote() {
|
||||
if git remote get-url gitea >/dev/null 2>&1; then
|
||||
echo "gitea"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
echo "origin"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
REMOTE="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "[ERROR] Current directory is not a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$REMOTE" ]]; then
|
||||
if ! REMOTE="$(resolve_default_remote)"; then
|
||||
echo "[ERROR] No default remote found. Expected 'gitea' or 'origin'."
|
||||
echo "Available remotes:"
|
||||
git remote -v || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||
echo "[ERROR] Remote '$REMOTE' does not exist."
|
||||
echo "Available remotes:"
|
||||
git remote -v
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git rev-parse --short HEAD)"
|
||||
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
|
||||
|
||||
echo "[INFO] HEAD: ${HEAD_SHA}"
|
||||
echo "[INFO] Recreate annotated tag '${TAG}'"
|
||||
git tag -fa "$TAG" -m "$MESSAGE"
|
||||
|
||||
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
|
||||
git push "$REMOTE" "refs/tags/${TAG}" --force
|
||||
|
||||
echo "[INFO] Verify remote tag reference"
|
||||
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
|
||||
|
||||
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
|
||||
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_geocoding_module():
|
||||
module_path = Path(__file__).resolve().parents[2] / "app" / "services" / "geocoding.py"
|
||||
spec = importlib.util.spec_from_file_location("tests_geocoding_under_test", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
geocoding = _load_geocoding_module()
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.calls = []
|
||||
|
||||
async def get(self, url, *, params):
|
||||
self.calls.append({"url": url, "params": params})
|
||||
return self.response
|
||||
|
||||
|
||||
def test_geocode_tianditu_gets_expected_params(monkeypatch):
|
||||
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
|
||||
monkeypatch.setattr(
|
||||
geocoding.settings,
|
||||
"TIANDITU_GEOCODER_URL",
|
||||
"https://api.tianditu.gov.cn/geocoder",
|
||||
)
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"location": {"lon": "116.407526", "lat": "39.904030", "level": "地名地址"},
|
||||
"status": "0",
|
||||
"msg": "ok",
|
||||
},
|
||||
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
|
||||
)
|
||||
client = FakeClient(response)
|
||||
|
||||
result = asyncio.run(
|
||||
geocoding.geocode_tianditu(
|
||||
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
|
||||
client=client,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["location"] == {
|
||||
"lon": "116.407526",
|
||||
"lat": "39.904030",
|
||||
"level": "地名地址",
|
||||
}
|
||||
assert client.calls == [
|
||||
{
|
||||
"url": "https://api.tianditu.gov.cn/geocoder",
|
||||
"params": {
|
||||
"ds": json.dumps({"keyWord": "北京市人民政府"}, ensure_ascii=False),
|
||||
"tk": "tk-test",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_geocode_tianditu_accepts_key_word_alias(monkeypatch):
|
||||
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"location": {"lon": "116", "lat": "39"}, "status": "0", "msg": "ok"},
|
||||
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
geocoding.geocode_tianditu(
|
||||
geocoding.TiandituGeocodeRequest(keyWord="北京市人民政府"),
|
||||
client=FakeClient(response),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["status"] == "0"
|
||||
|
||||
|
||||
def test_geocode_tianditu_requires_token(monkeypatch):
|
||||
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "")
|
||||
|
||||
with pytest.raises(geocoding.TiandituGeocodingConfigError):
|
||||
asyncio.run(
|
||||
geocoding.geocode_tianditu(
|
||||
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
|
||||
client=FakeClient(httpx.Response(200, json={})),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_geocode_tianditu_surfaces_http_error(monkeypatch):
|
||||
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
|
||||
response = httpx.Response(
|
||||
403,
|
||||
json={"msg": "invalid tk"},
|
||||
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
|
||||
)
|
||||
|
||||
with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info:
|
||||
asyncio.run(
|
||||
geocoding.geocode_tianditu(
|
||||
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
|
||||
client=FakeClient(response),
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == {"msg": "invalid tk"}
|
||||
|
||||
|
||||
def test_geocode_tianditu_surfaces_tianditu_error_status(monkeypatch):
|
||||
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"status": "100", "msg": "bad request"},
|
||||
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
|
||||
)
|
||||
|
||||
with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info:
|
||||
asyncio.run(
|
||||
geocoding.geocode_tianditu(
|
||||
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
|
||||
client=FakeClient(response),
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 502
|
||||
assert exc_info.value.detail == {"status": "100", "msg": "bad request"}
|
||||
@@ -0,0 +1,115 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_web_search_module():
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2] / "app" / "services" / "web_search.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("tests_web_search_under_test", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
web_search = _load_web_search_module()
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.calls = []
|
||||
|
||||
async def post(self, url, *, headers, json):
|
||||
self.calls.append({"url": url, "headers": headers, "json": json})
|
||||
return self.response
|
||||
|
||||
|
||||
def test_search_bocha_web_posts_expected_payload(monkeypatch):
|
||||
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(
|
||||
web_search.settings,
|
||||
"BOCHA_WEB_SEARCH_URL",
|
||||
"https://api.bochaai.com/v1/web-search",
|
||||
)
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"data": {"webPages": {"value": []}}},
|
||||
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
|
||||
)
|
||||
client = FakeClient(response)
|
||||
|
||||
result = asyncio.run(
|
||||
web_search.search_bocha_web(
|
||||
web_search.WebSearchRequest(
|
||||
query="天津水务",
|
||||
freshness="oneWeek",
|
||||
summary=True,
|
||||
count=5,
|
||||
include=["example.com", "news.example.com"],
|
||||
exclude=["spam.example.com"],
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"data": {"webPages": {"value": []}}}
|
||||
assert client.calls == [
|
||||
{
|
||||
"url": "https://api.bochaai.com/v1/web-search",
|
||||
"headers": {
|
||||
"Authorization": "Bearer sk-test",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"json": {
|
||||
"query": "天津水务",
|
||||
"freshness": "oneWeek",
|
||||
"summary": True,
|
||||
"count": 5,
|
||||
"include": "example.com,news.example.com",
|
||||
"exclude": "spam.example.com",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_search_bocha_web_requires_api_key(monkeypatch):
|
||||
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "")
|
||||
|
||||
with pytest.raises(web_search.BochaSearchConfigError):
|
||||
asyncio.run(
|
||||
web_search.search_bocha_web(
|
||||
web_search.WebSearchRequest(query="天津水务"),
|
||||
client=FakeClient(httpx.Response(200, json={})),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_search_bocha_web_surfaces_upstream_error(monkeypatch):
|
||||
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
|
||||
response = httpx.Response(
|
||||
401,
|
||||
json={"error": "invalid api key"},
|
||||
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
|
||||
)
|
||||
|
||||
with pytest.raises(web_search.BochaSearchAPIError) as exc_info:
|
||||
asyncio.run(
|
||||
web_search.search_bocha_web(
|
||||
web_search.WebSearchRequest(query="天津水务"),
|
||||
client=FakeClient(response),
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == {"error": "invalid api key"}
|
||||
|
||||
|
||||
def test_web_search_request_validates_count_range():
|
||||
with pytest.raises(ValueError):
|
||||
web_search.WebSearchRequest(query="天津水务", count=51)
|
||||
Reference in New Issue
Block a user