Compare commits
20 Commits
b72e42521c
...
agent-mvp
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa8e55748 | |||
| 7a9fcaae81 | |||
| a1e9673d9a | |||
| e588d1cf33 | |||
| 1712ecd4c7 | |||
| 441979f581 | |||
| e336ffcd46 | |||
| 52b8f07abd | |||
| 7efaeb41e8 | |||
| 9a7aad2d36 | |||
| b7872f29a9 | |||
| 233960d8db | |||
| b9410b0ff3 | |||
| 4982efba5e | |||
| f87dd91b2b | |||
| c16e6e3d0c | |||
| 40e699e173 | |||
| 9b8a517092 | |||
| f274cf5122 | |||
| 60db2a7193 |
@@ -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_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||||
KEYCLOAK_ALGORITHM=RS256
|
KEYCLOAK_ALGORITHM=RS256
|
||||||
KEYCLOAK_AUDIENCE="account"
|
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
|
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 避免编译问题)
|
# 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
|
||||||
RUN mamba install -y python=3.12 pymetis && \
|
RUN mamba install -y python=3.12 pymetis && \
|
||||||
mamba clean -afy
|
mamba clean -afy
|
||||||
|
|
||||||
COPY requirements.txt .
|
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
|
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 app ./app
|
||||||
COPY db_inp ./db_inp
|
RUN mkdir -p ./db_inp
|
||||||
COPY .env .
|
|
||||||
|
|
||||||
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
|
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
|
|||||||
@@ -1,424 +0,0 @@
|
|||||||
# Agent CLI 接口范围确认
|
|
||||||
|
|
||||||
本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。
|
|
||||||
|
|
||||||
```text
|
|
||||||
tjwater auth
|
|
||||||
tjwater project
|
|
||||||
tjwater network
|
|
||||||
tjwater component
|
|
||||||
tjwater simulation
|
|
||||||
tjwater analysis
|
|
||||||
tjwater data
|
|
||||||
tjwater help
|
|
||||||
tjwater result
|
|
||||||
```
|
|
||||||
|
|
||||||
首批默认不暴露:
|
|
||||||
|
|
||||||
- 会修改 network 的接口:`add*`、`set*`、`delete*`、`generate*`
|
|
||||||
- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制
|
|
||||||
- 数据写入/清理接口:insert、update、delete、clean、clear、batch store
|
|
||||||
- 用户管理接口:创建、更新、删除、激活、停用
|
|
||||||
- 快照回滚和批量命令执行接口:undo、redo、pick、batch
|
|
||||||
|
|
||||||
## 设计原则
|
|
||||||
|
|
||||||
- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。
|
|
||||||
- 首批只暴露 `schema`、`list`、`get`、`exists`、只读计算和分析类能力。
|
|
||||||
- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。
|
|
||||||
- CLI 输出统一使用 JSON;大结果写入 result-ref,只在 stdout 返回摘要、路径和元数据。
|
|
||||||
- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。
|
|
||||||
- 高频命令可以提供 alias,但文档和 skill 只写规范命令。
|
|
||||||
|
|
||||||
## 分级约束
|
|
||||||
|
|
||||||
| 顶层命令 | 二级范围 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `auth` | `me`、`refresh` | 登录态和当前用户 |
|
|
||||||
| `project` | `list`、`info`、`status`、`export-inp`、`data` | 项目发现和只读项目数据 |
|
|
||||||
| `network` | `list`、`get`、`schema`、`exists`、`geometry`、`region`、`tag` | 管网拓扑、元素、几何、分区,只读 |
|
|
||||||
| `component` | `curve`、`pattern`、`option`、`control`、`quality`、`visual` | EPANET 组件类能力 |
|
|
||||||
| `simulation` | `run`、`run-inp`、`output` | 模拟运行和模拟输出 |
|
|
||||||
| `analysis` | `burst`、`leakage`、`valve`、`flushing`、`age`、`sensor-placement`、`risk` | 任务级分析 |
|
|
||||||
| `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 |
|
|
||||||
| `help` | `--json`、`COMMAND --json` | Agent 能力发现和命令说明 |
|
|
||||||
| `result` | `show`、`metadata`、`export` | `result-ref` 读取和导出 |
|
|
||||||
|
|
||||||
命令深度建议:
|
|
||||||
|
|
||||||
- 常规命令不超过 3 层:`tjwater component curve list`
|
|
||||||
- 时序数据允许 4 层:`tjwater data timeseries realtime links`
|
|
||||||
- `risk` 归入 `analysis risk`
|
|
||||||
- `scada`、`scheme`、`extension` 归入 `data`
|
|
||||||
|
|
||||||
## 首批 CLI 范围
|
|
||||||
|
|
||||||
### Auth / Project
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/api/v1/endpoints/auth.py
|
|
||||||
app/api/v1/endpoints/meta.py
|
|
||||||
app/api/v1/endpoints/project.py
|
|
||||||
app/api/v1/endpoints/project_data.py
|
|
||||||
```
|
|
||||||
|
|
||||||
| 命令 | 覆盖接口 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `tjwater auth me` | `GET /auth/me` | 当前登录用户 |
|
|
||||||
| `tjwater auth refresh` | `POST /auth/refresh` | 仅在 CLI 需要维护登录态时暴露 |
|
|
||||||
| `tjwater project list` | `GET /meta/projects` | 项目列表 |
|
|
||||||
| `tjwater project info --project PROJECT` | `GET /meta/project` | 项目信息 |
|
|
||||||
| `tjwater project db-health --project PROJECT` | `GET /meta/db/health` | 项目数据库健康 |
|
|
||||||
| `tjwater project export-inp --project PROJECT --out-ref` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出 INP,写 `result-ref` |
|
|
||||||
| `tjwater project data --project PROJECT --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 项目业务数据 |
|
|
||||||
|
|
||||||
暂不暴露:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /auth/register
|
|
||||||
POST /auth/login
|
|
||||||
POST /auth/login/simple
|
|
||||||
GET /listprojects/
|
|
||||||
GET /project_info/
|
|
||||||
GET /haveproject/
|
|
||||||
GET /isprojectopen/
|
|
||||||
GET /isprojectlocked/
|
|
||||||
GET /isprojectlockedbyme/
|
|
||||||
POST /createproject/
|
|
||||||
POST /deleteproject/
|
|
||||||
POST /openproject/
|
|
||||||
POST /closeproject/
|
|
||||||
POST /copyproject/
|
|
||||||
POST /importinp/
|
|
||||||
POST /readinp/
|
|
||||||
POST /lockproject/
|
|
||||||
POST /unlockproject/
|
|
||||||
POST /uploadinp/
|
|
||||||
GET /convertv3tov2/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/api/v1/endpoints/network/*.py
|
|
||||||
```
|
|
||||||
|
|
||||||
| 命令 | 覆盖接口 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `tjwater network list --network NET --type nodes\|links` | `GET /getnodes/`、`GET /getlinks/` | 节点/管线 ID 列表 |
|
|
||||||
| `tjwater network exists --network NET --type node\|link\|junction\|pipe\|... --id ID` | `GET /isnode/`、`GET /islink/` 等 | 元素存在性 |
|
|
||||||
| `tjwater network type --network NET --id ID` | `GET /getnodetype/`、`GET /getlinktype/`、`GET /getelementtype/` | 元素类型 |
|
|
||||||
| `tjwater network get --network NET --id ID` | `GET /getelementproperties/`、`GET /getnodeproperties/`、`GET /getlinkproperties/` | 自动识别类型并取属性 |
|
|
||||||
| `tjwater network get --network NET --type junction\|pipe\|pump\|... --id ID` | 各类 `get*properties` | 指定类型取属性 |
|
|
||||||
| `tjwater network list-properties --network NET --type junction\|pipe\|pump\|... --out-ref` | 各类 `getall*properties` | 全量属性,写 `result-ref` |
|
|
||||||
| `tjwater network schema --network NET --type junction\|reservoir\|tank\|pipe\|pump\|valve\|demand\|tag\|region` | 各类 `get*schema` | 属性架构 |
|
|
||||||
| `tjwater network links-of-node --network NET --node NODE` | `GET /getnodelinks/` | 节点关联管线 |
|
|
||||||
| `tjwater network geometry --network NET --scope full\|extent\|major-nodes\|major-pipes\|link-nodes --out-ref` | `geometry.py` 下 `GET` 接口 | 几何数据 |
|
|
||||||
| `tjwater network demand-calc --network NET --scope node\|region\|network --out-ref` | `GET /calculatedemandto*/` | 需水量计算 |
|
|
||||||
| `tjwater network region get\|list\|schema --network NET --kind dma\|service-area\|virtual-district` | `regions.py` 下 `GET` 查询接口 | 分区信息 |
|
|
||||||
| `tjwater network region-calc --network NET --kind dma\|service-area\|virtual-district --out-ref` | `GET /calculate*/` | 分区计算 |
|
|
||||||
| `tjwater network tag get\|list\|schema --network NET` | `GET /gettag/`、`GET /gettags/`、`GET /gettagschema/` | 标签信息 |
|
|
||||||
|
|
||||||
暂不暴露:
|
|
||||||
|
|
||||||
```text
|
|
||||||
add*
|
|
||||||
set*
|
|
||||||
delete*
|
|
||||||
generate*
|
|
||||||
POST /generatedistrictmeteringarea/
|
|
||||||
POST /generatesubdistrictmeteringarea/
|
|
||||||
POST /generateservicearea/
|
|
||||||
POST /generatevirtualdistrict/
|
|
||||||
```
|
|
||||||
|
|
||||||
备注:`GET /settitle/` 语义是修改标题,首批不暴露。
|
|
||||||
|
|
||||||
### Component
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/api/v1/endpoints/components/*.py
|
|
||||||
```
|
|
||||||
|
|
||||||
| 命令 | 覆盖接口 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `tjwater component curve schema\|list\|get\|exists` | `curves.py` 下只读接口 | 曲线 |
|
|
||||||
| `tjwater component pattern schema\|list\|get\|exists` | `patterns.py` 下只读接口 | 模式 |
|
|
||||||
| `tjwater component option schema\|get --kind time\|energy\|pump-energy\|general` | `options.py` 下只读接口 | 时间、能耗、泵能耗、通用选项 |
|
|
||||||
| `tjwater component control schema\|get --kind control\|rule` | `controls.py` 下只读接口 | 控制和规则 |
|
|
||||||
| `tjwater component quality schema\|get --kind quality\|emitter\|source\|reaction\|pipe-reaction\|tank-reaction\|mixing` | `quality.py` 下只读接口 | 水质相关组件 |
|
|
||||||
| `tjwater component visual schema\|list\|get --kind vertex\|label\|backdrop\|vertex-links\|vertices` | `visuals.py` 下只读接口 | 图形元素、标签、背景 |
|
|
||||||
|
|
||||||
暂不暴露:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /addcurve/
|
|
||||||
POST /setcurveproperties/
|
|
||||||
POST /deletecurve/
|
|
||||||
POST /addpattern/
|
|
||||||
POST /setpatternproperties/
|
|
||||||
POST /deletepattern/
|
|
||||||
POST /settimeproperties/
|
|
||||||
POST /setenergyproperties/
|
|
||||||
GET /setpumpenergyproperties//
|
|
||||||
POST /setoptionproperties/
|
|
||||||
POST /setcontrolproperties/
|
|
||||||
POST /setruleproperties/
|
|
||||||
POST /setqualityproperties/
|
|
||||||
POST /setemitterproperties/
|
|
||||||
POST /setsource/
|
|
||||||
POST /addsource/
|
|
||||||
POST /deletesource/
|
|
||||||
POST /setreaction/
|
|
||||||
POST /setpipereaction/
|
|
||||||
POST /settankreaction/
|
|
||||||
POST /setmixing/
|
|
||||||
POST /addmixing/
|
|
||||||
POST /deletemixing/
|
|
||||||
POST /setvertexproperties/
|
|
||||||
POST /addvertex/
|
|
||||||
POST /deletevertex/
|
|
||||||
POST /setlabelproperties/
|
|
||||||
POST /addlabel/
|
|
||||||
POST /deletelabel/
|
|
||||||
POST /setbackdropproperties/
|
|
||||||
```
|
|
||||||
|
|
||||||
备注:
|
|
||||||
|
|
||||||
- `getsourcechema` 路径拼写疑似错误,CLI 统一使用 `component quality schema --kind source`。
|
|
||||||
- `getallvertexlinks`、`getallvertices` 当前返回 JSON 字符串,CLI 应输出标准 JSON。
|
|
||||||
|
|
||||||
### Simulation / Analysis / Risk
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/api/v1/endpoints/simulation.py
|
|
||||||
app/api/v1/endpoints/leakage.py
|
|
||||||
app/api/v1/endpoints/burst_detection.py
|
|
||||||
app/api/v1/endpoints/burst_location.py
|
|
||||||
app/api/v1/endpoints/risk.py
|
|
||||||
```
|
|
||||||
|
|
||||||
| 命令 | 覆盖接口 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `tjwater simulation run --project PROJECT --out-ref` | `GET /runprojectreturndict/` | 运行项目模拟,使用结构化 JSON 返回 |
|
|
||||||
| `tjwater simulation run-inp --inp PATH --out-ref` | `GET /runinp/` | 运行 INP |
|
|
||||||
| `tjwater simulation output --project PROJECT --out-ref` | `GET /dumpoutput/` | 导出模拟输出 |
|
|
||||||
| `tjwater analysis burst --project PROJECT --start-time TIME --duration SEC --burst ID:SIZE --out-ref` | `GET /burst_analysis/` | 爆管分析,`--burst` 可重复 |
|
|
||||||
| `tjwater analysis valve --project PROJECT --mode close\|isolation --start-time TIME --valve VALVE --out-ref` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 |
|
|
||||||
| `tjwater analysis flushing --project PROJECT --start-time TIME --valve VALVE:OPENING --drainage-node NODE --flow FLOW --out-ref` | `GET /flushing_analysis/` | 冲洗分析,`--valve` 可重复 |
|
|
||||||
| `tjwater analysis age --project PROJECT --start-time TIME --duration SEC --out-ref` | `GET /age_analysis/` | 水龄分析 |
|
|
||||||
| `tjwater analysis contaminant --project PROJECT --start-time TIME --duration SEC --source NODE:VALUE --out-ref` | `GET /contaminant_simulation/` | 污染物模拟 |
|
|
||||||
| `tjwater analysis sensor-placement --project PROJECT --method sensitivity\|kmeans --count N --out-ref` | 传感器放置分析接口 | 不包含创建方案 |
|
|
||||||
| `tjwater analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /leakage/identify/` | 漏损识别 |
|
|
||||||
| `tjwater analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 |
|
|
||||||
| `tjwater analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /burst-detection/detect/` | 爆管检测 |
|
|
||||||
| `tjwater analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 |
|
|
||||||
| `tjwater analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /burst-location/locate/` | 爆管定位 |
|
|
||||||
| `tjwater analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 |
|
|
||||||
| `tjwater analysis risk pipe --network NET --pipe PIPE --time-range ...` | `risk.py` 下管道风险 `GET` 接口 | 管道风险 |
|
|
||||||
| `tjwater analysis risk network --network NET --out-ref` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 全网风险 |
|
|
||||||
|
|
||||||
暂缓或暂不暴露:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /network_project/
|
|
||||||
GET /runproject/
|
|
||||||
POST /network_update/
|
|
||||||
POST /project_management/
|
|
||||||
POST /sensorplacementscheme/create
|
|
||||||
POST /runsimulationmanuallybydate/
|
|
||||||
POST /pump_failure/
|
|
||||||
POST /pressure_regulation/
|
|
||||||
POST /scheduling_analysis/
|
|
||||||
POST /daily_scheduling_analysis/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/api/v1/endpoints/timeseries/*.py
|
|
||||||
app/api/v1/endpoints/scada.py
|
|
||||||
app/api/v1/endpoints/schemes.py
|
|
||||||
app/api/v1/endpoints/extension.py
|
|
||||||
app/api/v1/endpoints/misc.py
|
|
||||||
app/api/v1/endpoints/project_data.py
|
|
||||||
```
|
|
||||||
|
|
||||||
| 命令 | 覆盖接口 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `tjwater data timeseries realtime links --start-time TIME --end-time TIME --out-ref` | `GET /realtime/links` | 实时管道数据 |
|
|
||||||
| `tjwater data timeseries realtime nodes --start-time TIME --end-time TIME --out-ref` | `GET /realtime/nodes` | 实时节点数据 |
|
|
||||||
| `tjwater data timeseries realtime simulation --query by-id-time\|by-time-property --id ID --time TIME --property PROPERTY --out-ref` | `GET /realtime/query/*` | 实时模拟查询 |
|
|
||||||
| `tjwater data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 |
|
|
||||||
| `tjwater data timeseries scheme node-field --node NODE --field FIELD --out-ref` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 |
|
|
||||||
| `tjwater data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY --out-ref` | `GET /scheme/query/*` | 方案模拟查询 |
|
|
||||||
| `tjwater data timeseries scada query --device-ids ... --time-range ... --out-ref` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序 |
|
|
||||||
| `tjwater data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME --out-ref` | `GET /composite/*` | 复合查询,`--feature` 可重复 |
|
|
||||||
| `tjwater data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME --out-ref` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
|
|
||||||
| `tjwater data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` |
|
|
||||||
| `tjwater data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 |
|
|
||||||
| `tjwater data scheme schema\|get\|list --network NET` | `schemes.py` 下 `GET` 接口 | 方案查询 |
|
|
||||||
| `tjwater data extension keys\|get\|list --network NET` | `extension.py` 下 `GET` 查询接口 | 扩展数据查询 |
|
|
||||||
| `tjwater data misc sensor-placements --network NET --out-ref` | `GET /getallsensorplacements/` | 传感器位置 |
|
|
||||||
| `tjwater data misc burst-location-results --network NET --out-ref` | `GET /getallburstlocateresults/` | 爆管定位结果 |
|
|
||||||
|
|
||||||
暂不暴露:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /realtime/*/batch
|
|
||||||
DELETE /realtime/*
|
|
||||||
PATCH /realtime/*
|
|
||||||
POST /realtime/simulation/store
|
|
||||||
POST /scheme/*/batch
|
|
||||||
PATCH /scheme/*
|
|
||||||
DELETE /scheme/*
|
|
||||||
POST /scheme/simulation/store
|
|
||||||
POST /scada/batch
|
|
||||||
PATCH /scada/{device_id}/field
|
|
||||||
DELETE /scada/by-id-time-range
|
|
||||||
POST /composite/clean-scada
|
|
||||||
POST /setscadadevice/
|
|
||||||
POST /addscadadevice/
|
|
||||||
POST /deletescadadevice/
|
|
||||||
POST /cleanscadadevice/
|
|
||||||
POST /setscadadevicedata/
|
|
||||||
POST /addscadadevicedata/
|
|
||||||
POST /deletescadadevicedata/
|
|
||||||
POST /cleanscadadevicedata/
|
|
||||||
POST /setscadaelement/
|
|
||||||
POST /addscadaelement/
|
|
||||||
POST /deletescadaelement/
|
|
||||||
POST /cleanscadaelement/
|
|
||||||
POST /setextensiondata/
|
|
||||||
POST /test_dict/
|
|
||||||
GET /getjson/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 不纳入首批 CLI 的运维接口
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/api/v1/endpoints/snapshots.py
|
|
||||||
app/api/v1/endpoints/cache.py
|
|
||||||
app/api/v1/endpoints/audit.py
|
|
||||||
app/api/v1/endpoints/users.py
|
|
||||||
app/api/v1/endpoints/user_management.py
|
|
||||||
```
|
|
||||||
|
|
||||||
这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计、用户管理或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。
|
|
||||||
|
|
||||||
暂不暴露:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /getcurrentoperationid/
|
|
||||||
GET /getsnapshots/
|
|
||||||
GET /havesnapshot/
|
|
||||||
GET /havesnapshotforoperation/
|
|
||||||
GET /havesnapshotforcurrentoperation/
|
|
||||||
GET /getrestoreoperation/
|
|
||||||
POST /undo/
|
|
||||||
POST /redo/
|
|
||||||
POST /takesnapshot*/
|
|
||||||
POST /picksnapshot/
|
|
||||||
POST /pickoperation/
|
|
||||||
GET /syncwithserver/
|
|
||||||
POST /batch/
|
|
||||||
POST /compressedbatch/
|
|
||||||
POST /setrestoreoperation/
|
|
||||||
GET /queryredis/
|
|
||||||
POST /clearrediskey/
|
|
||||||
POST /clearrediskeys/
|
|
||||||
POST /clearallredis/
|
|
||||||
GET /audit/logs
|
|
||||||
GET /audit/logs/my
|
|
||||||
GET /audit/logs/count
|
|
||||||
GET /getuserschema/
|
|
||||||
GET /getuser/
|
|
||||||
GET /getallusers/
|
|
||||||
PUT /users/{user_id}
|
|
||||||
DELETE /users/{user_id}
|
|
||||||
POST /users/{user_id}/activate
|
|
||||||
POST /users/{user_id}/deactivate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Help / Result
|
|
||||||
|
|
||||||
这两个模块不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `tjwater help --json` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 |
|
|
||||||
| `tjwater help COMMAND --json` | 返回某个命令的参数、输出、示例和推荐后续命令 |
|
|
||||||
| `tjwater result show REF` | 读取 `result-ref` 内容,必要时分页或摘要 |
|
|
||||||
| `tjwater result metadata REF` | 读取 `result-ref` 元数据 |
|
|
||||||
| `tjwater result export REF --format json\|csv` | 导出结果 |
|
|
||||||
|
|
||||||
## 输出规范
|
|
||||||
|
|
||||||
成功:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"summary": "读取成功",
|
|
||||||
"data": {},
|
|
||||||
"result_ref": null,
|
|
||||||
"metadata": {},
|
|
||||||
"next_commands": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
失败:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": false,
|
|
||||||
"error": "invalid_argument",
|
|
||||||
"message": "缺少必要参数 --network",
|
|
||||||
"recoverable": true,
|
|
||||||
"suggested_command": "tjwater component curve list --network NET"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
大结果:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"summary": "查询完成,结果已写入 result-ref",
|
|
||||||
"result_ref": "TJWaterAgent/data/result-refs/example.json",
|
|
||||||
"metadata": {
|
|
||||||
"schema": "network_properties_v1",
|
|
||||||
"rows": 1200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 后续开放条件
|
|
||||||
|
|
||||||
如后续要开放写操作,需要单独设计:
|
|
||||||
|
|
||||||
- 权限校验
|
|
||||||
- dry-run / preview
|
|
||||||
- 显式确认机制
|
|
||||||
- 审计日志
|
|
||||||
- 变更快照
|
|
||||||
- 回滚策略
|
|
||||||
- Agent 可读的错误恢复建议
|
|
||||||
@@ -662,7 +662,7 @@ def age_analysis(
|
|||||||
new_name,
|
new_name,
|
||||||
"realtime",
|
"realtime",
|
||||||
modify_pattern_start_time,
|
modify_pattern_start_time,
|
||||||
modify_total_duration,
|
duration=modify_total_duration,
|
||||||
downloading_prohibition=True,
|
downloading_prohibition=True,
|
||||||
)
|
)
|
||||||
simulation_result = json.loads(result)
|
simulation_result = json.loads(result)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
||||||
|
import psycopg
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
@@ -58,6 +59,7 @@ async def get_project_metadata(
|
|||||||
code=project.code,
|
code=project.code,
|
||||||
description=project.description,
|
description=project.description,
|
||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
|
map_extent=project.map_extent,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
project_role=ctx.project_role,
|
project_role=ctx.project_role,
|
||||||
geoserver=geoserver_payload,
|
geoserver=geoserver_payload,
|
||||||
@@ -110,7 +112,23 @@ async def project_db_health(
|
|||||||
|
|
||||||
检查PostgreSQL和TimescaleDB数据库的连接状态
|
检查PostgreSQL和TimescaleDB数据库的连接状态
|
||||||
"""
|
"""
|
||||||
await pg_session.execute(text("SELECT 1"))
|
try:
|
||||||
async with ts_conn.cursor() as cur:
|
await pg_session.execute(text("SELECT 1"))
|
||||||
await cur.execute("SELECT 1")
|
except SQLAlchemyError as exc:
|
||||||
|
logger.error("Project PostgreSQL health check failed", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project PostgreSQL health check failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ts_conn.cursor() as cur:
|
||||||
|
await cur.execute("SELECT 1")
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
logger.error("Project TimescaleDB health check failed", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project TimescaleDB health check failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
return {"postgres": "ok", "timescale": "ok"}
|
return {"postgres": "ok", "timescale": "ok"}
|
||||||
|
|||||||
@@ -35,16 +35,22 @@ from app.services.simulation_ops import (
|
|||||||
daily_scheduling_simulation,
|
daily_scheduling_simulation,
|
||||||
)
|
)
|
||||||
from app.services.valve_isolation import analyze_valve_isolation
|
from app.services.valve_isolation import analyze_valve_isolation
|
||||||
from pydantic import BaseModel, Field
|
from app.services.time_api import parse_aware_time, parse_utc_time
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class RunSimulationManuallyByDate(BaseModel):
|
class RunSimulationManuallyByDate(BaseModel):
|
||||||
name: str = Field(..., description="管网名称(或数据库名称)")
|
name: str = Field(..., description="管网名称(或数据库名称)")
|
||||||
simulation_date: str = Field(..., description="模拟基准日期 (YYYY-MM-DD)")
|
start_time: str = Field(..., description="开始时间 (ISO 8601 / RFC3339,必须显式带时区)")
|
||||||
start_time: str = Field(..., description="开始时间 (HH:MM 或 HH:MM:SS)")
|
duration: int = Field(..., gt=0, description="持续时间 (分钟)")
|
||||||
duration: int = Field(..., description="持续时间 (分钟)")
|
|
||||||
|
@field_validator("start_time")
|
||||||
|
@classmethod
|
||||||
|
def validate_start_time_timezone(cls, value: str) -> str:
|
||||||
|
parse_aware_time(value, field_name="start_time")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class BurstAnalysis(BaseModel):
|
class BurstAnalysis(BaseModel):
|
||||||
@@ -109,28 +115,15 @@ class PressureSensorPlacement(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def run_simulation_manually_by_date(
|
def run_simulation_manually_by_date(
|
||||||
network_name: str, base_date: datetime, start_time: str, duration: int
|
network_name: str, start_time: datetime, duration: int
|
||||||
) -> None:
|
) -> None:
|
||||||
time_parts = list(map(int, start_time.split(":")))
|
end_datetime = start_time + timedelta(minutes=duration)
|
||||||
if len(time_parts) == 2:
|
current_time = start_time
|
||||||
start_hour, start_minute = time_parts
|
|
||||||
start_second = 0
|
|
||||||
elif len(time_parts) == 3:
|
|
||||||
start_hour, start_minute, start_second = time_parts
|
|
||||||
else:
|
|
||||||
raise ValueError("Invalid start_time format. Use HH:MM or HH:MM:SS")
|
|
||||||
|
|
||||||
start_datetime = base_date.replace(
|
|
||||||
hour=start_hour, minute=start_minute, second=start_second
|
|
||||||
)
|
|
||||||
end_datetime = start_datetime + timedelta(minutes=duration)
|
|
||||||
current_time = start_datetime
|
|
||||||
while current_time < end_datetime:
|
while current_time < end_datetime:
|
||||||
iso_time = current_time.strftime("%Y-%m-%dT%H:%M:%S") + "+08:00"
|
|
||||||
simulation.run_simulation(
|
simulation.run_simulation(
|
||||||
name=network_name,
|
name=network_name,
|
||||||
simulation_type="realtime",
|
simulation_type="realtime",
|
||||||
modify_pattern_start_time=iso_time,
|
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
|
||||||
)
|
)
|
||||||
current_time += timedelta(minutes=15)
|
current_time += timedelta(minutes=15)
|
||||||
|
|
||||||
@@ -233,6 +226,7 @@ async def fastapi_valve_close_analysis(
|
|||||||
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
|
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
|
||||||
valves: List[str] = Query(..., description="要关闭的阀门ID列表"),
|
valves: List[str] = Query(..., description="要关闭的阀门ID列表"),
|
||||||
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
||||||
|
scheme_name: str = Query(..., description="阀门关闭方案名称"),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
阀门关闭分析(高级版本)
|
阀门关闭分析(高级版本)
|
||||||
@@ -241,6 +235,7 @@ async def fastapi_valve_close_analysis(
|
|||||||
- **start_time**: 阀门关闭开始时间
|
- **start_time**: 阀门关闭开始时间
|
||||||
- **valves**: 要关闭的阀门ID列表
|
- **valves**: 要关闭的阀门ID列表
|
||||||
- **duration**: 模拟持续时间(秒,可选,默认900)
|
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||||||
|
- **scheme_name**: 阀门关闭方案名称
|
||||||
|
|
||||||
支持同时关闭多个阀门进行分析。
|
支持同时关闭多个阀门进行分析。
|
||||||
"""
|
"""
|
||||||
@@ -249,6 +244,7 @@ async def fastapi_valve_close_analysis(
|
|||||||
modify_pattern_start_time=start_time,
|
modify_pattern_start_time=start_time,
|
||||||
modify_total_duration=duration or 900,
|
modify_total_duration=duration or 900,
|
||||||
modify_valve_opening={valve_id: 0.0 for valve_id in valves},
|
modify_valve_opening={valve_id: 0.0 for valve_id in valves},
|
||||||
|
scheme_name=scheme_name,
|
||||||
)
|
)
|
||||||
return result or "success"
|
return result or "success"
|
||||||
|
|
||||||
@@ -302,7 +298,7 @@ async def fastapi_flushing_analysis(
|
|||||||
drainage_node_ID: str = Query(..., description="排污节点ID"),
|
drainage_node_ID: str = Query(..., description="排污节点ID"),
|
||||||
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
|
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
|
||||||
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
||||||
scheme_name: str | None = Query(None, description="冲洗方案名称(可选)"),
|
scheme_name: str = Query(..., description="冲洗方案名称"),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
冲洗分析(高级版本)
|
冲洗分析(高级版本)
|
||||||
@@ -314,7 +310,7 @@ async def fastapi_flushing_analysis(
|
|||||||
- **drainage_node_ID**: 排污节点ID
|
- **drainage_node_ID**: 排污节点ID
|
||||||
- **flush_flow**: 冲洗流量(L/s)
|
- **flush_flow**: 冲洗流量(L/s)
|
||||||
- **duration**: 模拟持续时间(秒,可选,默认900)
|
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||||||
- **scheme_name**: 冲洗方案名称(可选)
|
- **scheme_name**: 冲洗方案名称
|
||||||
|
|
||||||
支持多阀联合冲洗操作。
|
支持多阀联合冲洗操作。
|
||||||
"""
|
"""
|
||||||
@@ -340,7 +336,7 @@ async def fastapi_contaminant_simulation(
|
|||||||
source: str = Query(..., description="污染源节点ID"),
|
source: str = Query(..., description="污染源节点ID"),
|
||||||
concentration: float = Query(..., description="污染浓度(mg/L)"),
|
concentration: float = Query(..., description="污染浓度(mg/L)"),
|
||||||
duration: int = Query(..., description="模拟持续时间(秒)"),
|
duration: int = Query(..., description="模拟持续时间(秒)"),
|
||||||
scheme_name: str | None = Query(None, description="模拟方案名称(可选)"),
|
scheme_name: str = Query(..., description="模拟方案名称"),
|
||||||
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
|
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -351,7 +347,7 @@ async def fastapi_contaminant_simulation(
|
|||||||
- **source**: 污染源节点ID
|
- **source**: 污染源节点ID
|
||||||
- **concentration**: 污染浓度(mg/L)
|
- **concentration**: 污染浓度(mg/L)
|
||||||
- **duration**: 模拟持续时间(秒)
|
- **duration**: 模拟持续时间(秒)
|
||||||
- **scheme_name**: 模拟方案名称(可选)
|
- **scheme_name**: 模拟方案名称
|
||||||
- **pattern**: 污染源模式ID(可选)
|
- **pattern**: 污染源模式ID(可选)
|
||||||
|
|
||||||
用于评估管网中污染物的传播和影响范围。
|
用于评估管网中污染物的传播和影响范围。
|
||||||
@@ -767,7 +763,7 @@ async def fastapi_pressure_sensor_placement(
|
|||||||
return "success"
|
return "success"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的日期、开始时间和持续时间,手动运行水力模拟。系统将自动查询管网参数并执行模拟。")
|
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
|
||||||
async def fastapi_run_simulation_manually_by_date(
|
async def fastapi_run_simulation_manually_by_date(
|
||||||
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
|
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -776,14 +772,13 @@ async def fastapi_run_simulation_manually_by_date(
|
|||||||
|
|
||||||
请求体参数:
|
请求体参数:
|
||||||
- **name**: 管网名称(或数据库名称)
|
- **name**: 管网名称(或数据库名称)
|
||||||
- **simulation_date**: 模拟基准日期(YYYY-MM-DD格式)
|
- **start_time**: 开始时间(ISO 8601 / RFC3339,必须显式带时区)
|
||||||
- **start_time**: 开始时间(HH:MM或HH:MM:SS格式)
|
|
||||||
- **duration**: 模拟持续时间(分钟)
|
- **duration**: 模拟持续时间(分钟)
|
||||||
|
|
||||||
系统将从指定日期和时间开始,按15分钟间隔多次运行模拟。
|
系统将从指定时间开始,按15分钟间隔多次运行模拟。
|
||||||
每次模拟间隔15分钟,直至达到指定的总持续时间。
|
每次模拟间隔15分钟,直至达到指定的总持续时间。
|
||||||
"""
|
"""
|
||||||
item = data.dict()
|
item = data.model_dump()
|
||||||
try:
|
try:
|
||||||
simulation.query_corresponding_element_id_and_query_id(item["name"])
|
simulation.query_corresponding_element_id_and_query_id(item["name"])
|
||||||
simulation.query_corresponding_pattern_id_and_query_id(item["name"])
|
simulation.query_corresponding_pattern_id_and_query_id(item["name"])
|
||||||
@@ -810,10 +805,10 @@ async def fastapi_run_simulation_manually_by_date(
|
|||||||
globals.source_outflow_region_id,
|
globals.source_outflow_region_id,
|
||||||
globals.realtime_region_pipe_flow_and_demand_id,
|
globals.realtime_region_pipe_flow_and_demand_id,
|
||||||
)
|
)
|
||||||
base_date = datetime.strptime(item["simulation_date"], "%Y-%m-%d")
|
start_time = parse_utc_time(item["start_time"], field_name="start_time")
|
||||||
run_simulation_manually_by_date(
|
run_simulation_manually_by_date(
|
||||||
item["name"], base_date, item["start_time"], item["duration"]
|
item["name"], start_time, item["duration"]
|
||||||
)
|
)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"status": "error", "message": str(exc)}
|
raise HTTPException(status_code=500, detail=str(exc)) 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, # 新增:用户管理
|
user_management, # 新增:用户管理
|
||||||
audit, # 新增:审计日志
|
audit, # 新增:审计日志
|
||||||
meta,
|
meta,
|
||||||
|
web_search,
|
||||||
|
geocoding,
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.network import (
|
from app.api.v1.endpoints.network import (
|
||||||
general,
|
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(misc.router, tags=["Misc"])
|
||||||
api_router.include_router(risk.router, tags=["Risk"])
|
api_router.include_router(risk.router, tags=["Risk"])
|
||||||
api_router.include_router(cache.router, tags=["Cache"])
|
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(leakage.router, prefix="/leakage", tags=["Leakage"])
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
|
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ class Settings(BaseSettings):
|
|||||||
KEYCLOAK_ALGORITHM: str = "RS256"
|
KEYCLOAK_ALGORITHM: str = "RS256"
|
||||||
KEYCLOAK_AUDIENCE: str = ""
|
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
|
@property
|
||||||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||||||
db_password = quote_plus(self.DB_PASSWORD)
|
db_password = quote_plus(self.DB_PASSWORD)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
class GeoServerConfigResponse(BaseModel):
|
class GeoServerConfigResponse(BaseModel):
|
||||||
gs_base_url: Optional[str]
|
gs_base_url: Optional[str] = None
|
||||||
gs_admin_user: Optional[str]
|
gs_admin_user: Optional[str] = None
|
||||||
gs_datastore_name: str
|
gs_datastore_name: str
|
||||||
default_extent: Optional[dict]
|
default_extent: Optional[dict] = None
|
||||||
srid: int
|
srid: int
|
||||||
|
|
||||||
|
|
||||||
@@ -16,19 +16,19 @@ class ProjectMetaResponse(BaseModel):
|
|||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
description: Optional[str]
|
description: Optional[str] = None
|
||||||
gs_workspace: str
|
gs_workspace: str
|
||||||
map_extent: Optional[dict]
|
map_extent: Optional[dict] = None
|
||||||
status: str
|
status: str
|
||||||
project_role: str
|
project_role: str
|
||||||
geoserver: Optional[GeoServerConfigResponse]
|
geoserver: Optional[GeoServerConfigResponse] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectSummaryResponse(BaseModel):
|
class ProjectSummaryResponse(BaseModel):
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
description: Optional[str]
|
description: Optional[str] = None
|
||||||
gs_workspace: str
|
gs_workspace: str
|
||||||
status: str
|
status: str
|
||||||
project_role: str
|
project_role: str
|
||||||
|
|||||||
@@ -1,36 +1,5 @@
|
|||||||
from app.services.network_import import network_update, submit_scada_info
|
"""Service package.
|
||||||
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
|
|
||||||
|
|
||||||
__all__ = [
|
Keep package initialization lightweight. Import concrete service modules directly,
|
||||||
"network_update",
|
for example: `from app.services.tjnetwork import open_project`.
|
||||||
"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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,6 +34,7 @@ import psycopg
|
|||||||
import logging
|
import logging
|
||||||
import app.services.globals as globals
|
import app.services.globals as globals
|
||||||
import app.services.project_info as project_info
|
import app.services.project_info as project_info
|
||||||
|
from app.services.time_api import parse_beijing_time
|
||||||
from app.core.config import get_pgconn_string
|
from app.core.config import get_pgconn_string
|
||||||
from app.infra.db.timescaledb.internal_queries import (
|
from app.infra.db.timescaledb.internal_queries import (
|
||||||
InternalQueries as TimescaleInternalQueries,
|
InternalQueries as TimescaleInternalQueries,
|
||||||
@@ -661,13 +662,14 @@ def from_seconds_to_clock(secs: int) -> str:
|
|||||||
|
|
||||||
def convert_time_format(original_time: str) -> str:
|
def convert_time_format(original_time: str) -> str:
|
||||||
"""
|
"""
|
||||||
格式转换,将“2024-04-13T08:00:00+08:00"转为“2024-04-13 08:00:00”
|
格式转换,将带时区的 ISO 8601 / RFC3339 时间转为北京时间的“YYYY-MM-DD HH:MM:SS”
|
||||||
:param original_time: str, “2024-04-13T08:00:00+08:00"格式的时间
|
:param original_time: str,带显式时区的时间
|
||||||
:return: str,“2024-04-13 08:00:00”格式的时间
|
:return: str,“2024-04-13 08:00:00”格式的时间
|
||||||
"""
|
"""
|
||||||
new_time = original_time.replace("T", " ")
|
normalized_time = parse_beijing_time(
|
||||||
new_time = new_time.replace("+08:00", "")
|
original_time, field_name="modify_pattern_start_time"
|
||||||
return new_time
|
)
|
||||||
|
return normalized_time.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
def get_history_pattern_info(project_name, pattern_name):
|
def get_history_pattern_info(project_name, pattern_name):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist/
|
||||||
|
build/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# TJWater CLI
|
||||||
|
|
||||||
|
独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器使用**编译后的可执行文件**直接调用,并通过 stdout/stderr 参与管道。
|
||||||
|
|
||||||
|
## 构建可执行产物
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd TJWaterServerBinary/cli
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
python -m pip install -r requirements-build.txt
|
||||||
|
chmod +x build.sh
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
构建完成后,直接使用编译产物:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dist/tjwater-cli/tjwater-cli help
|
||||||
|
```
|
||||||
|
|
||||||
|
这个可执行文件可以直接参与管道:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dist/tjwater-cli/tjwater-cli help | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
当前采用 `PyInstaller onedir` 方式输出到 `dist/tjwater-cli/`,避免 onefile 在部分 agent/server 环境下依赖临时目录解包执行的问题。
|
||||||
|
|
||||||
|
如果需要在开发时直接走源码入口,也可以显式使用 Python:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m tjwater_cli help
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署到 agent 服务器
|
||||||
|
|
||||||
|
最简单的方式是把 `dist/tjwater-cli/` 整个目录同步到 agent 服务器,然后直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tjwater-cli/tjwater-cli help
|
||||||
|
```
|
||||||
|
|
||||||
|
如果希望打包传输:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd TJWaterServerBinary/cli
|
||||||
|
tar -C dist -czf tjwater-cli-linux-amd64.tar.gz tjwater-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
如果希望放到 PATH 中:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s /path/to/TJWaterServerBinary/cli/dist/tjwater-cli/tjwater-cli /usr/local/bin/tjwater-cli
|
||||||
|
tjwater-cli help | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行与构建依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd TJWaterServerBinary/cli
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
python -m pip install -r requirements-build.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements.txt` 仅包含运行 CLI 的依赖;`requirements-build.txt` 仅包含生成可执行文件所需的构建依赖。
|
||||||
|
|
||||||
|
## 认证上下文
|
||||||
|
|
||||||
|
CLI 通过 `--auth-context` 读取 JSON 文件。常用字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": "http://backend-host:8000",
|
||||||
|
"access_token": "...",
|
||||||
|
"project_id": "...",
|
||||||
|
"network": "...",
|
||||||
|
"username": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
if [ -n "${PYTHON:-}" ]; then
|
||||||
|
PYTHON_BIN="$PYTHON"
|
||||||
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
"$PYTHON_BIN" -m PyInstaller --noconfirm --clean tjwater.spec
|
||||||
|
|
||||||
|
BIN_PATH="$ROOT/dist/"
|
||||||
|
if [ ! -x "$BIN_PATH" ]; then
|
||||||
|
echo "build succeeded but executable was not created: $BIN_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$BIN_PATH" help >/dev/null
|
||||||
|
|
||||||
|
echo "built executable: $BIN_PATH"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from tjwater_cli.main import console_entry
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
console_entry()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"tjwater_cli",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"executionEnvironments": [
|
||||||
|
{
|
||||||
|
"root": ".",
|
||||||
|
"extraPaths": [
|
||||||
|
"."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pyinstaller>=6.11,<7
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
click>=8.1,<9
|
||||||
|
requests>=2.31,<3
|
||||||
|
typer>=0.12,<1
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
@@ -0,0 +1,963 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from tjwater_cli import common, core
|
||||||
|
from tjwater_cli.main import app, main
|
||||||
|
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
class DummyResponse:
|
||||||
|
def __init__(self, *, status_code=200, json_data=None, text="", headers=None, content=None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._json_data = json_data
|
||||||
|
self.text = text
|
||||||
|
self.headers = headers or {"content-type": "application/json"}
|
||||||
|
self.content = content if content is not None else text.encode("utf-8")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self):
|
||||||
|
return 200 <= self.status_code < 300
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if self._json_data is None:
|
||||||
|
raise ValueError("no json")
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_auth_context_supports_aliases(monkeypatch):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_PROJECT_ID", "p1")
|
||||||
|
monkeypatch.setenv("TJWATER_USER_ID", "u1")
|
||||||
|
monkeypatch.setenv("TJWATER_USERNAME", "tester")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "net1")
|
||||||
|
|
||||||
|
auth = core.load_auth_context(auth_stdin=False)
|
||||||
|
|
||||||
|
assert auth.server == "http://server"
|
||||||
|
assert auth.access_token == "abc"
|
||||||
|
assert auth.project_id == "p1"
|
||||||
|
assert auth.user_id == "u1"
|
||||||
|
assert auth.username == "tester"
|
||||||
|
assert auth.network == "net1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_runtime_context_uses_default_server(monkeypatch):
|
||||||
|
monkeypatch.delenv("TJWATER_SERVER", raising=False)
|
||||||
|
monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False)
|
||||||
|
monkeypatch.delenv("TJWATER_USER_ID", raising=False)
|
||||||
|
monkeypatch.delenv("TJWATER_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("TJWATER_NETWORK", raising=False)
|
||||||
|
monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False)
|
||||||
|
|
||||||
|
runtime = core.build_runtime_context(
|
||||||
|
server=None,
|
||||||
|
scheme=None,
|
||||||
|
timeout=core.DEFAULT_TIMEOUT,
|
||||||
|
request_id="req-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.server == core.DEFAULT_SERVER
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
|
||||||
|
observed_runtime_ids: list[int] = []
|
||||||
|
|
||||||
|
def fake_request_json(ctx, **kwargs):
|
||||||
|
observed_runtime_ids.append(id(ctx))
|
||||||
|
assert ctx.auth.access_token == "token-1"
|
||||||
|
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-junction-properties", "--junction", "11"],
|
||||||
|
input=json.dumps(
|
||||||
|
{
|
||||||
|
"server": "http://server",
|
||||||
|
"access_token": "token-1",
|
||||||
|
"project_id": "project-1",
|
||||||
|
"network": "tjwater",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["data"] == {"id": "11"}
|
||||||
|
assert len(observed_runtime_ids) == 1
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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-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",
|
||||||
|
"path": "/getjunctionproperties/",
|
||||||
|
"params": {"network": "tjwater", "junction": "J1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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-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",
|
||||||
|
"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():
|
||||||
|
result = runner.invoke(app, ["help"])
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert payload["schema_version"] == "tjwater-cli/v1"
|
||||||
|
assert any(command["command"] == "analysis" for command in payload["commands"])
|
||||||
|
assert all(command["command"] != "project" for command in payload["commands"])
|
||||||
|
assert payload["menu_level"] == 1
|
||||||
|
assert all(command["command"] != "project list" for command in payload["commands"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_option_json_is_removed():
|
||||||
|
result = runner.invoke(app, ["help", "--json"])
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "No such option: --json" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_simulation_help_lists_subcommands():
|
||||||
|
result = runner.invoke(app, ["simulation", "help"])
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert payload["summary"] == "模拟运行与调度相关命令。"
|
||||||
|
commands = {command["command"]: command for command in payload["commands"]}
|
||||||
|
assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行"
|
||||||
|
assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
|
||||||
|
assert "tjwater-cli" in commands["simulation run"]["example"]
|
||||||
|
assert "simulation run" in commands["simulation run"]["example"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_group_help_lists_examples():
|
||||||
|
result = runner.invoke(app, ["analysis", "leakage", "help"])
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert payload["summary"] == "漏损分析相关命令。"
|
||||||
|
commands = {command["command"]: command for command in payload["commands"]}
|
||||||
|
assert commands["analysis leakage identify"]["summary"] == "执行漏损识别"
|
||||||
|
assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_help_uses_group_summaries_for_nested_groups():
|
||||||
|
result = runner.invoke(app, ["analysis", "help"])
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
commands = {command["command"]: command for command in payload["commands"]}
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert commands["analysis leakage"]["summary"] == "漏损分析相关命令。"
|
||||||
|
assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。"
|
||||||
|
assert "analysis burst-location" not in commands
|
||||||
|
assert "analysis risk" not in commands
|
||||||
|
assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01"
|
||||||
|
assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_analysis_uses_typer_help_with_descriptions():
|
||||||
|
result = runner.invoke(app, ["analysis"])
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "分析计算与诊断相关命令。" in result.stdout
|
||||||
|
assert "burst 执行爆管分析" in result.stdout
|
||||||
|
assert "valve" in result.stdout
|
||||||
|
assert "leakage 漏损分析相关命令。" in result.stdout
|
||||||
|
assert "burst-location" not in result.stdout
|
||||||
|
assert "risk" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaf_help_outputs_json():
|
||||||
|
result = runner.invoke(app, ["help", "simulation", "run"])
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert payload["command"] == "simulation run"
|
||||||
|
assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询"
|
||||||
|
assert payload["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
|
||||||
|
assert len(payload["examples"]) == 1
|
||||||
|
assert "simulation run" in payload["examples"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_help_flag_uses_typer_style_with_examples():
|
||||||
|
result = runner.invoke(app, ["--help"], prog_name="tjwater-cli")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Usage: tjwater-cli" in result.stdout
|
||||||
|
assert "Examples:" in result.stdout
|
||||||
|
assert "tjwater-cli help simulation run" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaf_help_flag_includes_usage_and_example():
|
||||||
|
result = runner.invoke(app, ["simulation", "run", "--help"], prog_name="tjwater-cli")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Usage: tjwater-cli simulation run [OPTIONS]" in result.stdout
|
||||||
|
assert "Usage example:" in result.stdout
|
||||||
|
assert "--start-time <START_TIME>" in result.stdout
|
||||||
|
assert "--duration" in result.stdout
|
||||||
|
assert "Examples:" in result.stdout
|
||||||
|
assert "tjwater-cli simulation run" in result.stdout
|
||||||
|
assert "START_TIME" in result.stdout
|
||||||
|
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")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
burst_path = tmp_path / "burst.json"
|
||||||
|
burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8")
|
||||||
|
|
||||||
|
def fake_request(**kwargs):
|
||||||
|
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"analysis",
|
||||||
|
"burst",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--duration",
|
||||||
|
"30",
|
||||||
|
"--burst-file",
|
||||||
|
str(burst_path),
|
||||||
|
"--scheme",
|
||||||
|
"burst_case_01",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert '"summary": "爆管分析执行成功"' in result.stdout
|
||||||
|
assert "tjwater-cli data scheme get --name burst_case_01" in result.stdout
|
||||||
|
assert "tjwater-cli data scheme list" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_contaminant_sends_required_scheme_name(monkeypatch):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_request(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"analysis",
|
||||||
|
"contaminant",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--duration",
|
||||||
|
"900",
|
||||||
|
"--source-node",
|
||||||
|
"N1",
|
||||||
|
"--concentration",
|
||||||
|
"10.0",
|
||||||
|
"--scheme",
|
||||||
|
"contam_case_01",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert captured["params"] == {
|
||||||
|
"network": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"source": "N1",
|
||||||
|
"concentration": 10.0,
|
||||||
|
"duration": 900,
|
||||||
|
"scheme_name": "contam_case_01",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
captured = {}
|
||||||
|
valve_path = tmp_path / "valve.json"
|
||||||
|
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
|
||||||
|
|
||||||
|
def fake_request(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"analysis",
|
||||||
|
"flushing",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--valve-setting-file",
|
||||||
|
str(valve_path),
|
||||||
|
"--drainage-node",
|
||||||
|
"N1",
|
||||||
|
"--flow",
|
||||||
|
"100.0",
|
||||||
|
"--duration",
|
||||||
|
"900",
|
||||||
|
"--scheme",
|
||||||
|
"flush_case_01",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert captured["params"] == {
|
||||||
|
"network": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"valves": ["V1"],
|
||||||
|
"valves_k": [0.5],
|
||||||
|
"drainage_node_ID": "N1",
|
||||||
|
"flush_flow": 100.0,
|
||||||
|
"duration": 900,
|
||||||
|
"scheme_name": "flush_case_01",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_valve_close_sends_required_scheme_name(monkeypatch):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_request(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"analysis",
|
||||||
|
"valve",
|
||||||
|
"--mode",
|
||||||
|
"close",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--valve",
|
||||||
|
"V1",
|
||||||
|
"--duration",
|
||||||
|
"900",
|
||||||
|
"--scheme",
|
||||||
|
"valve_case_01",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert captured["params"] == {
|
||||||
|
"network": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"valves": ["V1"],
|
||||||
|
"duration": 900,
|
||||||
|
"scheme_name": "valve_case_01",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_contaminant_requires_scheme(monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"analysis",
|
||||||
|
"contaminant",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--duration",
|
||||||
|
"900",
|
||||||
|
"--source-node",
|
||||||
|
"N1",
|
||||||
|
"--concentration",
|
||||||
|
"10.0",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert exit_code == 2
|
||||||
|
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
valve_path = tmp_path / "valve.json"
|
||||||
|
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"analysis",
|
||||||
|
"flushing",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--valve-setting-file",
|
||||||
|
str(valve_path),
|
||||||
|
"--drainage-node",
|
||||||
|
"N1",
|
||||||
|
"--flow",
|
||||||
|
"100.0",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert exit_code == 2
|
||||||
|
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_valve_close_requires_scheme(monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"analysis",
|
||||||
|
"valve",
|
||||||
|
"--mode",
|
||||||
|
"close",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--valve",
|
||||||
|
"V1",
|
||||||
|
"--duration",
|
||||||
|
"900",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert exit_code == 2
|
||||||
|
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_missing_option_error_includes_usage_and_next_step(capsys):
|
||||||
|
exit_code = main(["simulation", "run"])
|
||||||
|
stdout = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert exit_code == 2
|
||||||
|
assert '"summary": "缺少参数"' in stdout
|
||||||
|
assert '"code": "MISSING_PARAMETER"' in stdout
|
||||||
|
assert '"usage": "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"' in stdout
|
||||||
|
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
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "Usage: tjwater-cli analysis" in stdout
|
||||||
|
assert "分析计算与诊断相关命令。" in stdout
|
||||||
|
assert '"ok": false' not in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_simulation_run_translates_rfc3339(monkeypatch):
|
||||||
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
|
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||||
|
monkeypatch.setenv("TJWATER_NETWORK", "demo")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_request(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return DummyResponse(json_data={"status": "success", "message": "Simulation started"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"simulation",
|
||||||
|
"run",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:04:05+08:00",
|
||||||
|
"--duration",
|
||||||
|
"30",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert captured["json"] == {
|
||||||
|
"name": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"duration": 30,
|
||||||
|
}
|
||||||
|
assert "tjwater-cli data timeseries realtime links" in result.stdout
|
||||||
|
assert "tjwater-cli data timeseries realtime nodes" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_project_command_returns_not_found(capsys):
|
||||||
|
exit_code = main(["project", "list"])
|
||||||
|
stdout = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert exit_code == 2
|
||||||
|
assert '"code": "COMMAND_NOT_FOUND"' in stdout or "No such command: project" in stdout
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
|
|
||||||
|
datas = collect_data_files("certifi")
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["entrypoint.py"],
|
||||||
|
pathex=["."],
|
||||||
|
binaries=[],
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name="tjwater-cli",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name="tjwater-cli",
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .main import app, main
|
||||||
|
|
||||||
|
__all__ = ["app", "main"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .main import console_entry
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
console_entry()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .formatters import TJWaterGroup
|
||||||
|
|
||||||
|
app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||||
|
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)
|
||||||
|
|
||||||
|
app.add_typer(network_app, name="network")
|
||||||
|
app.add_typer(component_app, name="component")
|
||||||
|
component_app.add_typer(component_option_app, name="option")
|
||||||
|
app.add_typer(simulation_app, name="simulation")
|
||||||
|
app.add_typer(analysis_app, name="analysis")
|
||||||
|
analysis_app.add_typer(analysis_sensor_placement_app, name="sensor-placement")
|
||||||
|
analysis_app.add_typer(analysis_leakage_app, name="leakage")
|
||||||
|
analysis_leakage_app.add_typer(analysis_leakage_schemes_app, name="schemes")
|
||||||
|
analysis_app.add_typer(analysis_burst_detection_app, name="burst-detection")
|
||||||
|
analysis_burst_detection_app.add_typer(analysis_burst_detection_schemes_app, name="schemes")
|
||||||
|
analysis_app.add_typer(analysis_burst_location_app, name="burst-location")
|
||||||
|
analysis_burst_location_app.add_typer(analysis_burst_location_schemes_app, name="schemes")
|
||||||
|
analysis_app.add_typer(analysis_risk_app, name="risk")
|
||||||
|
app.add_typer(data_app, name="data")
|
||||||
|
data_app.add_typer(data_timeseries_app, name="timeseries")
|
||||||
|
data_timeseries_app.add_typer(data_timeseries_realtime_app, name="realtime")
|
||||||
|
data_timeseries_app.add_typer(data_timeseries_scheme_app, name="scheme")
|
||||||
|
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")
|
||||||
|
|
||||||
|
GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
|
||||||
|
(network_app, ("network",)),
|
||||||
|
(component_app, ("component",)),
|
||||||
|
(component_option_app, ("component", "option")),
|
||||||
|
(simulation_app, ("simulation",)),
|
||||||
|
(analysis_app, ("analysis",)),
|
||||||
|
(analysis_sensor_placement_app, ("analysis", "sensor-placement")),
|
||||||
|
(analysis_leakage_app, ("analysis", "leakage")),
|
||||||
|
(analysis_leakage_schemes_app, ("analysis", "leakage", "schemes")),
|
||||||
|
(analysis_burst_detection_app, ("analysis", "burst-detection")),
|
||||||
|
(analysis_burst_detection_schemes_app, ("analysis", "burst-detection", "schemes")),
|
||||||
|
(analysis_burst_location_app, ("analysis", "burst-location")),
|
||||||
|
(analysis_burst_location_schemes_app, ("analysis", "burst-location", "schemes")),
|
||||||
|
(analysis_risk_app, ("analysis", "risk")),
|
||||||
|
(data_app, ("data",)),
|
||||||
|
(data_timeseries_app, ("data", "timeseries")),
|
||||||
|
(data_timeseries_realtime_app, ("data", "timeseries", "realtime")),
|
||||||
|
(data_timeseries_scheme_app, ("data", "timeseries", "scheme")),
|
||||||
|
(data_timeseries_scada_app, ("data", "timeseries", "scada")),
|
||||||
|
(data_timeseries_composite_app, ("data", "timeseries", "composite")),
|
||||||
|
(data_scada_app, ("data", "scada")),
|
||||||
|
(data_scheme_app, ("data", "scheme")),
|
||||||
|
]
|
||||||
|
|
||||||
|
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .apps import (
|
||||||
|
analysis_app,
|
||||||
|
analysis_burst_detection_app,
|
||||||
|
analysis_burst_detection_schemes_app,
|
||||||
|
analysis_burst_location_app,
|
||||||
|
analysis_burst_location_schemes_app,
|
||||||
|
analysis_leakage_app,
|
||||||
|
analysis_leakage_schemes_app,
|
||||||
|
analysis_risk_app,
|
||||||
|
analysis_sensor_placement_app,
|
||||||
|
simulation_app,
|
||||||
|
)
|
||||||
|
from .common import emit_api, runtime_context
|
||||||
|
from .core import (
|
||||||
|
CLIError,
|
||||||
|
emit_success,
|
||||||
|
parse_burst_file,
|
||||||
|
parse_optional_dataset_file,
|
||||||
|
parse_time_with_timezone,
|
||||||
|
parse_valve_setting_file,
|
||||||
|
request_json,
|
||||||
|
require_network,
|
||||||
|
require_username,
|
||||||
|
resolve_scheme,
|
||||||
|
)
|
||||||
|
from .option_types import DataSource, ValveMode
|
||||||
|
|
||||||
|
|
||||||
|
@simulation_app.command("run")
|
||||||
|
def simulation_run(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
duration: Annotated[int, typer.Option("--duration", help="持续分钟数")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
network = require_network(runtime)
|
||||||
|
parsed = parse_time_with_timezone(start_time, option_name="--start-time")
|
||||||
|
end_time = (parsed + timedelta(minutes=duration)).isoformat()
|
||||||
|
body = {
|
||||||
|
"name": network,
|
||||||
|
"start_time": parsed.replace(microsecond=0).isoformat(),
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="触发模拟成功",
|
||||||
|
method="POST",
|
||||||
|
path="/runsimulationmanuallybydate/",
|
||||||
|
json_body=body,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
next_commands=[
|
||||||
|
f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
|
||||||
|
f"tjwater-cli data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_app.command("burst")
|
||||||
|
def analysis_burst(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
|
||||||
|
burst_file: Annotated[Path, typer.Option("--burst-file", help="爆管输入 JSON 文件")],
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
ids, sizes = parse_burst_file(burst_file)
|
||||||
|
scheme_name = resolve_scheme(runtime, scheme, required=True)
|
||||||
|
params = {
|
||||||
|
"network": require_network(runtime),
|
||||||
|
"modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||||
|
"burst_ID": ids,
|
||||||
|
"burst_size": sizes,
|
||||||
|
"modify_total_duration": duration,
|
||||||
|
"scheme_name": scheme_name,
|
||||||
|
}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="爆管分析执行成功",
|
||||||
|
method="GET",
|
||||||
|
path="/burst_analysis/",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
next_commands=[
|
||||||
|
f"tjwater-cli data scheme get --name {scheme_name}",
|
||||||
|
"tjwater-cli data scheme list",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_app.command("valve")
|
||||||
|
def analysis_valve(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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,
|
||||||
|
disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None,
|
||||||
|
duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None,
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
network = require_network(runtime)
|
||||||
|
if mode == ValveMode.CLOSE:
|
||||||
|
if not start_time or not valve:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INVALID_VALVE_CLOSE_ARGS",
|
||||||
|
message="close mode requires --start-time and at least one --valve",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"network": network,
|
||||||
|
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||||
|
"valves": valve,
|
||||||
|
"duration": duration or 900,
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="阀门关闭分析执行成功",
|
||||||
|
method="GET",
|
||||||
|
path="/valve_close_analysis/",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if mode == ValveMode.ISOLATION:
|
||||||
|
if not element:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INVALID_VALVE_ISOLATION_ARGS",
|
||||||
|
message="isolation mode requires at least one --element",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
params = {"network": network, "accident_element": element}
|
||||||
|
if disabled_valve:
|
||||||
|
params["disabled_valves"] = disabled_valve
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="阀门隔离分析执行成功",
|
||||||
|
method="GET",
|
||||||
|
path="/valve_isolation_analysis/",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise AssertionError(f"unreachable valve mode: {mode}")
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_app.command("flushing")
|
||||||
|
def analysis_flushing(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
valve_setting_file: Annotated[Path, typer.Option("--valve-setting-file", help="阀门开度 JSON 文件")],
|
||||||
|
drainage_node: Annotated[str, typer.Option("--drainage-node", help="排污节点")],
|
||||||
|
flow: Annotated[float, typer.Option("--flow", help="冲洗流量")],
|
||||||
|
duration: Annotated[int | None, typer.Option("--duration", help="持续秒数")] = None,
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
valves, openings = parse_valve_setting_file(valve_setting_file)
|
||||||
|
params = {
|
||||||
|
"network": require_network(runtime),
|
||||||
|
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||||
|
"valves": valves,
|
||||||
|
"valves_k": openings,
|
||||||
|
"drainage_node_ID": drainage_node,
|
||||||
|
"flush_flow": flow,
|
||||||
|
"duration": duration or 900,
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="冲洗分析执行成功",
|
||||||
|
method="GET",
|
||||||
|
path="/flushing_analysis/",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_app.command("age")
|
||||||
|
def analysis_age(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="水龄分析执行成功",
|
||||||
|
method="GET",
|
||||||
|
path="/age_analysis/",
|
||||||
|
params={
|
||||||
|
"network": require_network(runtime),
|
||||||
|
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||||
|
"duration": duration,
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_app.command("contaminant")
|
||||||
|
def analysis_contaminant(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
|
||||||
|
source_node: Annotated[str, typer.Option("--source-node", help="污染源节点")],
|
||||||
|
concentration: Annotated[float, typer.Option("--concentration", help="浓度")],
|
||||||
|
pattern: Annotated[str | None, typer.Option("--pattern", help="模式 ID")] = None,
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
params = {
|
||||||
|
"network": require_network(runtime),
|
||||||
|
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||||
|
"source": source_node,
|
||||||
|
"concentration": concentration,
|
||||||
|
"duration": duration,
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
}
|
||||||
|
if pattern:
|
||||||
|
params["pattern"] = pattern
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="污染物模拟执行成功",
|
||||||
|
method="GET",
|
||||||
|
path="/contaminant_simulation/",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_sensor_placement_app.command("kmeans")
|
||||||
|
def analysis_sensor_placement_kmeans(
|
||||||
|
ctx: typer.Context,
|
||||||
|
count: Annotated[int, typer.Option("--count", help="传感器数量")],
|
||||||
|
min_diameter: Annotated[int, typer.Option("--min-diameter", help="最小管径")] = 0,
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
body = {
|
||||||
|
"name": require_network(runtime),
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
"sensor_number": count,
|
||||||
|
"min_diameter": min_diameter,
|
||||||
|
"username": require_username(runtime),
|
||||||
|
}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="传感器选址执行成功",
|
||||||
|
method="POST",
|
||||||
|
path="/pressure_sensor_placement_kmeans/",
|
||||||
|
json_body=body,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
require_username_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_leakage_app.command("identify")
|
||||||
|
def analysis_leakage_identify(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
body = {
|
||||||
|
"network": require_network(runtime),
|
||||||
|
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||||
|
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="漏损识别执行成功",
|
||||||
|
method="POST",
|
||||||
|
path="/leakage/identify/",
|
||||||
|
json_body=body,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_leakage_schemes_app.command("list")
|
||||||
|
def analysis_leakage_schemes_list(ctx: typer.Context) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取漏损方案列表成功",
|
||||||
|
method="GET",
|
||||||
|
path="/leakage/schemes/",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_leakage_schemes_app.command("get")
|
||||||
|
def analysis_leakage_schemes_get(
|
||||||
|
ctx: typer.Context,
|
||||||
|
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取漏损方案详情成功",
|
||||||
|
method="GET",
|
||||||
|
path=f"/leakage/schemes/{scheme_name}",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_burst_detection_app.command("detect")
|
||||||
|
def analysis_burst_detection_detect(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
body = {
|
||||||
|
"network": require_network(runtime),
|
||||||
|
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||||
|
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="爆管检测执行成功",
|
||||||
|
method="POST",
|
||||||
|
path="/burst-detection/detect/",
|
||||||
|
json_body=body,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_burst_detection_schemes_app.command("list")
|
||||||
|
def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取爆管检测方案列表成功",
|
||||||
|
method="GET",
|
||||||
|
path="/burst-detection/schemes/",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_burst_detection_schemes_app.command("get")
|
||||||
|
def analysis_burst_detection_schemes_get(
|
||||||
|
ctx: typer.Context,
|
||||||
|
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取爆管检测方案详情成功",
|
||||||
|
method="GET",
|
||||||
|
path=f"/burst-detection/schemes/{scheme_name}",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_burst_location_app.command("locate")
|
||||||
|
def analysis_burst_location_locate(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||||
|
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[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,
|
||||||
|
flow_file: Annotated[Path | None, typer.Option("--flow-file", help="包含 burst_flow/normal_flow 的 JSON 文件")] = None,
|
||||||
|
use_scada_flow: Annotated[bool, typer.Option("--use-scada-flow", help="启用 SCADA 流量")] = False,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {}
|
||||||
|
flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {}
|
||||||
|
body = {
|
||||||
|
"network": require_network(runtime),
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
"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,
|
||||||
|
"use_scada_flow": use_scada_flow,
|
||||||
|
}
|
||||||
|
if pressure_scada_id:
|
||||||
|
body["pressure_scada_ids"] = pressure_scada_id
|
||||||
|
if flow_scada_id:
|
||||||
|
body["flow_scada_ids"] = flow_scada_id
|
||||||
|
if isinstance(pressure_payload, dict):
|
||||||
|
body.update({key: value for key, value in pressure_payload.items() if key in {"burst_pressure", "normal_pressure"}})
|
||||||
|
if isinstance(flow_payload, dict):
|
||||||
|
body.update({key: value for key, value in flow_payload.items() if key in {"burst_flow", "normal_flow"}})
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="爆管定位执行成功",
|
||||||
|
method="POST",
|
||||||
|
path="/burst-location/locate/",
|
||||||
|
json_body=body,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_burst_location_schemes_app.command("list")
|
||||||
|
def analysis_burst_location_schemes_list(ctx: typer.Context) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取爆管定位方案列表成功",
|
||||||
|
method="GET",
|
||||||
|
path="/burst-location/schemes/",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_burst_location_schemes_app.command("get")
|
||||||
|
def analysis_burst_location_schemes_get(
|
||||||
|
ctx: typer.Context,
|
||||||
|
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取爆管定位方案详情成功",
|
||||||
|
method="GET",
|
||||||
|
path=f"/burst-location/schemes/{scheme_name}",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_risk_app.command("pipe-now")
|
||||||
|
def analysis_risk_pipe_now(
|
||||||
|
ctx: typer.Context,
|
||||||
|
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取当前管道风险成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getpiperiskprobabilitynow/",
|
||||||
|
params={"network": require_network(runtime), "pipe_id": pipe},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_risk_app.command("pipe-history")
|
||||||
|
def analysis_risk_pipe_history(
|
||||||
|
ctx: typer.Context,
|
||||||
|
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取历史管道风险成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getpiperiskprobability/",
|
||||||
|
params={"network": require_network(runtime), "pipe_id": pipe},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analysis_risk_app.command("network")
|
||||||
|
def analysis_risk_network(ctx: typer.Context) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
network = require_network(runtime)
|
||||||
|
probabilities, duration_prob = request_json(
|
||||||
|
runtime,
|
||||||
|
method="GET",
|
||||||
|
path="/getnetworkpiperiskprobabilitynow/",
|
||||||
|
params={"network": network},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
geometries, duration_geo = request_json(
|
||||||
|
runtime,
|
||||||
|
method="GET",
|
||||||
|
path="/getpiperiskprobabilitygeometries/",
|
||||||
|
params={"network": network},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
emit_success(
|
||||||
|
summary="读取全网风险成功",
|
||||||
|
data={"probabilities": probabilities, "geometries": geometries},
|
||||||
|
ctx=runtime,
|
||||||
|
duration_ms=duration_prob + duration_geo,
|
||||||
|
)
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .apps import (
|
||||||
|
data_scada_app,
|
||||||
|
data_scheme_app,
|
||||||
|
data_timeseries_composite_app,
|
||||||
|
data_timeseries_realtime_app,
|
||||||
|
data_timeseries_scada_app,
|
||||||
|
data_timeseries_scheme_app,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||||
|
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||||
|
) -> None:
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取实时管道数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/realtime/links",
|
||||||
|
params={
|
||||||
|
"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(),
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_realtime_app.command("nodes")
|
||||||
|
def data_realtime_nodes(
|
||||||
|
ctx: typer.Context,
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||||
|
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||||
|
) -> None:
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取实时节点数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/realtime/nodes",
|
||||||
|
params={
|
||||||
|
"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(),
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_realtime_app.command("simulation-by-id-time")
|
||||||
|
def data_realtime_simulation_by_id_time(
|
||||||
|
ctx: typer.Context,
|
||||||
|
id: Annotated[str, typer.Option("--id", help="元素 ID")],
|
||||||
|
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")],
|
||||||
|
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||||
|
) -> None:
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取实时模拟数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/realtime/query/by-id-time",
|
||||||
|
params={
|
||||||
|
"id": id,
|
||||||
|
"type": type.value,
|
||||||
|
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_realtime_app.command("simulation-by-time-property")
|
||||||
|
def data_realtime_simulation_by_time_property(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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="属性名;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.value,
|
||||||
|
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||||
|
"property": property,
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_scheme_app.command("links")
|
||||||
|
def data_scheme_links(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取方案管道数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/scheme/links",
|
||||||
|
params={
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
"scheme_type": _scheme_type_option(scheme_type),
|
||||||
|
"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(),
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_scheme_app.command("node-field")
|
||||||
|
def data_scheme_node_field(
|
||||||
|
ctx: typer.Context,
|
||||||
|
node: Annotated[str, typer.Option("--node", help="节点 ID")],
|
||||||
|
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="读取方案节点字段成功",
|
||||||
|
method="GET",
|
||||||
|
path=f"/scheme/nodes/{node}/field",
|
||||||
|
params={
|
||||||
|
"field": field,
|
||||||
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
|
"scheme_type": _scheme_type_option(scheme_type),
|
||||||
|
"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(),
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_scheme_app.command("simulation")
|
||||||
|
def data_scheme_simulation(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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[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.value,
|
||||||
|
}
|
||||||
|
if query == SimulationQuery.BY_ID_TIME:
|
||||||
|
if not id:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="ID_REQUIRED",
|
||||||
|
message="--id is required for --query by-id-time",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
params["id"] = id
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取方案单点模拟数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/scheme/query/by-id-time",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY:
|
||||||
|
if not property:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="PROPERTY_REQUIRED",
|
||||||
|
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,
|
||||||
|
summary="读取方案属性聚合数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/scheme/query/by-scheme-time-property",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise AssertionError(f"unreachable query variant: {query}")
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_scada_app.command("query")
|
||||||
|
def data_scada_query(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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="字段名,仅支持 monitored_value|cleaned_value")] = None,
|
||||||
|
) -> None:
|
||||||
|
path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range"
|
||||||
|
params = {
|
||||||
|
"device_ids": ",".join(device_id),
|
||||||
|
"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 field:
|
||||||
|
field = _validate_scada_field(field, option_name="--field")
|
||||||
|
params["field"] = field
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取 SCADA 时序成功",
|
||||||
|
method="GET",
|
||||||
|
path=path,
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_composite_app.callback(invoke_without_command=True)
|
||||||
|
def data_timeseries_composite(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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,
|
||||||
|
pipe: Annotated[str | None, typer.Option("--pipe", help="pipeline-health 用管道 ID")] = None,
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
|
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||||
|
use_cleaned: Annotated[bool, typer.Option("--use-cleaned", help="element-scada 使用清洗值")] = False,
|
||||||
|
) -> None:
|
||||||
|
_ = pipe
|
||||||
|
if ctx.invoked_subcommand is not None:
|
||||||
|
return
|
||||||
|
if not kind or not start_time or not end_time:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INVALID_COMPOSITE_ARGS",
|
||||||
|
message="composite query requires --kind, --start-time, and --end-time",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
params = {
|
||||||
|
"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 == CompositeKind.SCADA_SIMULATION:
|
||||||
|
if not feature:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="FEATURE_REQUIRED",
|
||||||
|
message="--feature is required for scada-simulation",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
params["device_ids"] = ",".join(feature)
|
||||||
|
scheme_name = resolve_scheme(runtime, scheme)
|
||||||
|
if scheme_name:
|
||||||
|
params["scheme_name"] = scheme_name
|
||||||
|
params["scheme_type"] = _scheme_type_option(scheme_type)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取复合 SCADA-模拟数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/composite/scada-simulation",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if kind == CompositeKind.ELEMENT_SIMULATION:
|
||||||
|
if not feature:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="FEATURE_REQUIRED",
|
||||||
|
message="--feature is required for element-simulation",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
params["feature_infos"] = ",".join(feature)
|
||||||
|
scheme_name = resolve_scheme(runtime, scheme)
|
||||||
|
if scheme_name:
|
||||||
|
params["scheme_name"] = scheme_name
|
||||||
|
params["scheme_type"] = _scheme_type_option(scheme_type)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取复合元素模拟数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/composite/element-simulation",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if kind == CompositeKind.ELEMENT_SCADA:
|
||||||
|
if not feature or len(feature) != 1:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="FEATURE_REQUIRED",
|
||||||
|
message="element-scada requires exactly one --feature as element_id",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
params["element_id"] = feature[0]
|
||||||
|
params["use_cleaned"] = use_cleaned
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取元素关联 SCADA 数据成功",
|
||||||
|
method="GET",
|
||||||
|
path="/composite/element-scada",
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise AssertionError(f"unreachable composite kind: {kind}")
|
||||||
|
|
||||||
|
|
||||||
|
@data_timeseries_composite_app.command("pipeline-health")
|
||||||
|
def data_composite_pipeline_health(
|
||||||
|
ctx: typer.Context,
|
||||||
|
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||||
|
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||||
|
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||||
|
) -> None:
|
||||||
|
_ = pipe, start_time
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取管道健康预测成功",
|
||||||
|
method="GET",
|
||||||
|
path="/composite/pipeline-health-prediction",
|
||||||
|
params={
|
||||||
|
"network_name": require_network(runtime_context(ctx)),
|
||||||
|
"query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||||
|
},
|
||||||
|
require_auth=True,
|
||||||
|
require_project=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
|
||||||
|
mapping = {
|
||||||
|
("info", "get"): ("/getscadainfo/", {"id_param": "id"}),
|
||||||
|
("info", "list"): ("/getallscadainfo/", {}),
|
||||||
|
}
|
||||||
|
result = mapping.get((kind, action))
|
||||||
|
if result is None:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INVALID_SCADA_KIND",
|
||||||
|
message=f"unsupported scada {action} kind: {kind}",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@data_scada_app.command("get")
|
||||||
|
def data_scada_get(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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.value, "get")
|
||||||
|
params = {"network": require_network(runtime), meta["id_param"]: id}
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取 SCADA 数据成功",
|
||||||
|
method="GET",
|
||||||
|
path=path,
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_scada_app.command("list")
|
||||||
|
def data_scada_list(
|
||||||
|
ctx: typer.Context,
|
||||||
|
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
path, _ = _scada_mapping(kind.value, "list")
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取 SCADA 列表成功",
|
||||||
|
method="GET",
|
||||||
|
path=path,
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_scheme_app.command("schema")
|
||||||
|
def data_scheme_schema(ctx: typer.Context) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取方案 schema 成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getschemeschema/",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_scheme_app.command("get")
|
||||||
|
def data_scheme_get(
|
||||||
|
ctx: typer.Context,
|
||||||
|
name: Annotated[str, typer.Option("--name", help="方案名称")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取方案成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getscheme/",
|
||||||
|
params={"network": require_network(runtime), "schema_name": name},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@data_scheme_app.command("list")
|
||||||
|
def data_scheme_list(ctx: typer.Context) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取方案列表成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getallschemes/",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
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-junction-properties")
|
||||||
|
def network_get_junction_properties(
|
||||||
|
ctx: typer.Context,
|
||||||
|
junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取节点属性成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getjunctionproperties/",
|
||||||
|
params={"network": require_network(runtime), "junction": junction},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@network_app.command("get-pipe-properties")
|
||||||
|
def network_get_pipe_properties(
|
||||||
|
ctx: typer.Context,
|
||||||
|
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取管道属性成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getpipeproperties/",
|
||||||
|
params={"network": require_network(runtime), "pipe": pipe},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@network_app.command("get-all-pipes-properties")
|
||||||
|
def network_get_all_pipes_properties(ctx: typer.Context) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取全部管道属性成功",
|
||||||
|
method="GET",
|
||||||
|
path="/getallpipeproperties/",
|
||||||
|
params={"network": require_network(runtime)},
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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[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.value, schema=True)
|
||||||
|
params = {"network": require_network(runtime)}
|
||||||
|
if kind == ComponentOptionKind.PUMP_ENERGY and pump:
|
||||||
|
params["pump"] = pump
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取选项 schema 成功",
|
||||||
|
method="GET",
|
||||||
|
path=path,
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@component_option_app.command("get")
|
||||||
|
def component_option_get(
|
||||||
|
ctx: typer.Context,
|
||||||
|
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.value, schema=False)
|
||||||
|
params = {"network": require_network(runtime)}
|
||||||
|
if kind == ComponentOptionKind.PUMP_ENERGY:
|
||||||
|
if not pump:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="PUMP_REQUIRED",
|
||||||
|
message="--pump is required when --kind pump-energy",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
params["pump"] = pump
|
||||||
|
emit_api(
|
||||||
|
ctx,
|
||||||
|
summary="读取选项属性成功",
|
||||||
|
method="GET",
|
||||||
|
path=path,
|
||||||
|
params=params,
|
||||||
|
require_auth=True,
|
||||||
|
require_network_ctx=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _component_option_path(kind: str, *, schema: bool) -> str:
|
||||||
|
routes = {
|
||||||
|
("time", True): "/gettimeschema",
|
||||||
|
("time", False): "/gettimeproperties/",
|
||||||
|
("energy", True): "/getenergyschema/",
|
||||||
|
("energy", False): "/getenergyproperties/",
|
||||||
|
("pump-energy", True): "/getpumpenergyschema/",
|
||||||
|
("pump-energy", False): "/getpumpenergyproperties//",
|
||||||
|
("network", True): "/getoptionschema/",
|
||||||
|
("network", False): "/getoptionproperties/",
|
||||||
|
}
|
||||||
|
path = routes.get((kind, schema))
|
||||||
|
if path is None:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INVALID_KIND",
|
||||||
|
message="--kind must be one of time, energy, pump-energy, network",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
return path
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .core import DEFAULT_TIMEOUT, build_runtime_context, emit_success, request_json
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_context(ctx: typer.Context):
|
||||||
|
obj = ctx.obj
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
obj = {}
|
||||||
|
ctx.obj = obj
|
||||||
|
|
||||||
|
cached_runtime = obj.get("_runtime_context")
|
||||||
|
if cached_runtime is not None:
|
||||||
|
return cached_runtime
|
||||||
|
|
||||||
|
runtime = build_runtime_context(
|
||||||
|
server=obj.get("server"),
|
||||||
|
auth_stdin=obj.get("auth_stdin", False),
|
||||||
|
scheme=obj.get("scheme"),
|
||||||
|
timeout=obj.get("timeout", DEFAULT_TIMEOUT),
|
||||||
|
request_id=obj.get("request_id"),
|
||||||
|
)
|
||||||
|
obj["_runtime_context"] = runtime
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
def emit_api(
|
||||||
|
ctx: typer.Context,
|
||||||
|
*,
|
||||||
|
summary: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json_body: Any = None,
|
||||||
|
require_auth: bool = True,
|
||||||
|
require_project: bool = False,
|
||||||
|
require_network_ctx: bool = False,
|
||||||
|
require_username_ctx: bool = False,
|
||||||
|
next_commands: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
runtime = runtime_context(ctx)
|
||||||
|
data, duration_ms = request_json(
|
||||||
|
runtime,
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
params=params,
|
||||||
|
json_body=json_body,
|
||||||
|
require_auth=require_auth,
|
||||||
|
require_project=require_project,
|
||||||
|
require_network_ctx=require_network_ctx,
|
||||||
|
require_username_ctx=require_username_ctx,
|
||||||
|
)
|
||||||
|
emit_success(
|
||||||
|
summary=summary,
|
||||||
|
data=data,
|
||||||
|
ctx=runtime,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
next_commands=next_commands,
|
||||||
|
)
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import typer
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "tjwater-cli/v1"
|
||||||
|
CLI_NAME = "tjwater-cli"
|
||||||
|
DEFAULT_TIMEOUT = 180
|
||||||
|
DEFAULT_SERVER = "http://192.168.1.114:8000"
|
||||||
|
|
||||||
|
|
||||||
|
class CLIError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
summary: str,
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
exit_code: int,
|
||||||
|
retryable: bool = False,
|
||||||
|
next_commands: list[str] | None = None,
|
||||||
|
data: Any = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.summary = summary
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.exit_code = exit_code
|
||||||
|
self.retryable = retryable
|
||||||
|
self.next_commands = next_commands or []
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthContext:
|
||||||
|
server: str | None = None
|
||||||
|
access_token: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
network: str | None = None
|
||||||
|
headers: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeContext:
|
||||||
|
server: str | None
|
||||||
|
auth: AuthContext
|
||||||
|
scheme: str | None
|
||||||
|
timeout: int
|
||||||
|
request_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandOptionDoc:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
required: bool = False
|
||||||
|
repeated: bool = False
|
||||||
|
default: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandDoc:
|
||||||
|
path: tuple[str, ...]
|
||||||
|
summary: str
|
||||||
|
description: str
|
||||||
|
options: tuple[CommandOptionDoc, ...] = ()
|
||||||
|
examples: tuple[str, ...] = ()
|
||||||
|
next_commands: tuple[str, ...] = ()
|
||||||
|
output: str = "标准 JSON 输出"
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(mapping: Mapping[str, Any], *keys: str) -> Any:
|
||||||
|
for key in keys:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if value not in (None, ""):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_auth_context(auth_stdin: bool = False) -> AuthContext:
|
||||||
|
if auth_stdin:
|
||||||
|
raw = json.loads(sys.stdin.read())
|
||||||
|
else:
|
||||||
|
extra_headers = os.getenv("TJWATER_EXTRA_HEADERS")
|
||||||
|
raw = {
|
||||||
|
"server": os.getenv("TJWATER_SERVER"),
|
||||||
|
"access_token": os.getenv("TJWATER_ACCESS_TOKEN"),
|
||||||
|
"project_id": os.getenv("TJWATER_PROJECT_ID"),
|
||||||
|
"user_id": os.getenv("TJWATER_USER_ID"),
|
||||||
|
"username": os.getenv("TJWATER_USERNAME"),
|
||||||
|
"network": os.getenv("TJWATER_NETWORK"),
|
||||||
|
"headers": json.loads(extra_headers) if extra_headers else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = raw.get("headers") or {}
|
||||||
|
if not isinstance(headers, dict):
|
||||||
|
raise CLIError(
|
||||||
|
"认证失败",
|
||||||
|
code="AUTH_CONTEXT_INVALID",
|
||||||
|
message="auth context headers must be a JSON object",
|
||||||
|
exit_code=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuthContext(
|
||||||
|
server=_pick(raw, "server", "base_url"),
|
||||||
|
access_token=_pick(raw, "access_token", "token", "accessToken"),
|
||||||
|
project_id=_pick(raw, "project_id", "projectId", "x_project_id"),
|
||||||
|
user_id=_pick(raw, "user_id", "userId", "x_user_id"),
|
||||||
|
username=_pick(raw, "username", "preferred_username"),
|
||||||
|
network=_pick(raw, "network", "project_code", "projectCode", "project"),
|
||||||
|
headers={str(key): str(value) for key, value in headers.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_context(
|
||||||
|
*,
|
||||||
|
server: str | None,
|
||||||
|
auth_stdin: bool = False,
|
||||||
|
scheme: str | None,
|
||||||
|
timeout: int,
|
||||||
|
request_id: str | None,
|
||||||
|
) -> RuntimeContext:
|
||||||
|
auth = load_auth_context(auth_stdin=auth_stdin)
|
||||||
|
resolved_request_id = request_id or str(uuid.uuid4())
|
||||||
|
return RuntimeContext(
|
||||||
|
server=server or auth.server or DEFAULT_SERVER,
|
||||||
|
auth=auth,
|
||||||
|
scheme=scheme,
|
||||||
|
timeout=timeout,
|
||||||
|
request_id=resolved_request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_server(ctx: RuntimeContext) -> str:
|
||||||
|
if ctx.server:
|
||||||
|
return ctx.server.rstrip("/")
|
||||||
|
raise CLIError(
|
||||||
|
"认证失败",
|
||||||
|
code="SERVER_REQUIRED",
|
||||||
|
message="missing server URL; use --server or include server in auth context",
|
||||||
|
exit_code=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_access_token(ctx: RuntimeContext) -> str:
|
||||||
|
if ctx.auth.access_token:
|
||||||
|
return ctx.auth.access_token
|
||||||
|
raise CLIError(
|
||||||
|
"认证失败",
|
||||||
|
code="UNAUTHENTICATED",
|
||||||
|
message="missing access token for agent context",
|
||||||
|
exit_code=3,
|
||||||
|
next_commands=["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_project_id(ctx: RuntimeContext) -> str:
|
||||||
|
if ctx.auth.project_id:
|
||||||
|
return ctx.auth.project_id
|
||||||
|
raise CLIError(
|
||||||
|
"认证失败",
|
||||||
|
code="PROJECT_CONTEXT_REQUIRED",
|
||||||
|
message="missing project_id for agent context",
|
||||||
|
exit_code=3,
|
||||||
|
next_commands=["add project_id to auth context"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_network(ctx: RuntimeContext) -> str:
|
||||||
|
if ctx.auth.network:
|
||||||
|
return ctx.auth.network
|
||||||
|
raise CLIError(
|
||||||
|
"认证失败",
|
||||||
|
code="NETWORK_CONTEXT_REQUIRED",
|
||||||
|
message="missing network in auth context for legacy network-based endpoints",
|
||||||
|
exit_code=3,
|
||||||
|
next_commands=["add network to auth context"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_username(ctx: RuntimeContext) -> str:
|
||||||
|
if ctx.auth.username:
|
||||||
|
return ctx.auth.username
|
||||||
|
raise CLIError(
|
||||||
|
"认证失败",
|
||||||
|
code="USERNAME_CONTEXT_REQUIRED",
|
||||||
|
message="missing username in auth context",
|
||||||
|
exit_code=3,
|
||||||
|
next_commands=["add username to auth context"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None:
|
||||||
|
scheme = explicit_scheme or ctx.scheme
|
||||||
|
if required and not scheme:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="SCHEME_REQUIRED",
|
||||||
|
message="missing scheme; use --scheme",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
return scheme
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_with_timezone(value: str, *, option_name: str) -> datetime:
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INVALID_TIME",
|
||||||
|
message=f"{option_name} must be a valid ISO 8601 / RFC 3339 timestamp",
|
||||||
|
exit_code=2,
|
||||||
|
) from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="TIMEZONE_REQUIRED",
|
||||||
|
message=f"{option_name} must include an explicit timezone offset",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_input(path: Path, *, label: str) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INPUT_NOT_FOUND",
|
||||||
|
message=f"{label} file not found: {path}",
|
||||||
|
exit_code=2,
|
||||||
|
) from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="INPUT_INVALID_JSON",
|
||||||
|
message=f"{label} file must be valid JSON: {path}",
|
||||||
|
exit_code=2,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_burst_file(path: Path) -> tuple[list[str], list[float]]:
|
||||||
|
raw = read_json_input(path, label="burst")
|
||||||
|
if isinstance(raw, dict) and "bursts" in raw:
|
||||||
|
raw = raw["bursts"]
|
||||||
|
if isinstance(raw, dict) and "burst_ID" in raw and "burst_size" in raw:
|
||||||
|
ids = [str(item) for item in raw["burst_ID"]]
|
||||||
|
sizes = [float(item) for item in raw["burst_size"]]
|
||||||
|
if len(ids) != len(sizes):
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="BURST_FILE_INVALID",
|
||||||
|
message="burst file burst_ID and burst_size must have the same length",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
return ids, sizes
|
||||||
|
if isinstance(raw, list):
|
||||||
|
ids: list[str] = []
|
||||||
|
sizes: list[float] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict) or "id" not in item or "size" not in item:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="BURST_FILE_INVALID",
|
||||||
|
message="burst file items must contain id and size",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
ids.append(str(item["id"]))
|
||||||
|
sizes.append(float(item["size"]))
|
||||||
|
return ids, sizes
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="BURST_FILE_INVALID",
|
||||||
|
message="burst file must be a JSON array or object with burst_ID/burst_size",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_valve_setting_file(path: Path) -> tuple[list[str], list[float]]:
|
||||||
|
raw = read_json_input(path, label="valve-setting")
|
||||||
|
if isinstance(raw, dict) and "valves" in raw and "valves_k" in raw:
|
||||||
|
valves = [str(item) for item in raw["valves"]]
|
||||||
|
openings = [float(item) for item in raw["valves_k"]]
|
||||||
|
if len(valves) != len(openings):
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="VALVE_SETTING_INVALID",
|
||||||
|
message="valves and valves_k must have the same length",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
return valves, openings
|
||||||
|
if isinstance(raw, list):
|
||||||
|
valves: list[str] = []
|
||||||
|
openings: list[float] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict) or "valve" not in item or "opening" not in item:
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="VALVE_SETTING_INVALID",
|
||||||
|
message="valve-setting items must contain valve and opening",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
valves.append(str(item["valve"]))
|
||||||
|
openings.append(float(item["opening"]))
|
||||||
|
return valves, openings
|
||||||
|
raise CLIError(
|
||||||
|
"CLI 参数错误",
|
||||||
|
code="VALVE_SETTING_INVALID",
|
||||||
|
message="valve-setting file must be a JSON array or object with valves/valves_k",
|
||||||
|
exit_code=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_optional_dataset_file(path: Path | None, *, label: str) -> Any:
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
return read_json_input(path, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers(
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
*,
|
||||||
|
require_auth: bool,
|
||||||
|
require_project: bool,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"X-Request-Id": ctx.request_id,
|
||||||
|
}
|
||||||
|
headers.update(ctx.auth.headers)
|
||||||
|
if require_auth:
|
||||||
|
headers["Authorization"] = f"Bearer {require_access_token(ctx)}"
|
||||||
|
elif ctx.auth.access_token:
|
||||||
|
headers["Authorization"] = f"Bearer {ctx.auth.access_token}"
|
||||||
|
if require_project:
|
||||||
|
headers["X-Project-Id"] = require_project_id(ctx)
|
||||||
|
elif ctx.auth.project_id:
|
||||||
|
headers["X-Project-Id"] = ctx.auth.project_id
|
||||||
|
if ctx.auth.user_id:
|
||||||
|
headers["X-User-Id"] = ctx.auth.user_id
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_error_message(response: requests.Response) -> str:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
text = response.text.strip()
|
||||||
|
return text or f"http {response.status_code}"
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
detail = payload.get("detail")
|
||||||
|
if isinstance(detail, str):
|
||||||
|
return detail
|
||||||
|
if isinstance(detail, list):
|
||||||
|
return "; ".join(json.dumps(item, ensure_ascii=False) for item in detail)
|
||||||
|
message = payload.get("message")
|
||||||
|
if isinstance(message, str):
|
||||||
|
return message
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def map_http_status_to_exit_code(status_code: int) -> int:
|
||||||
|
if status_code in (400, 422):
|
||||||
|
return 2
|
||||||
|
if status_code == 401:
|
||||||
|
return 3
|
||||||
|
if status_code == 403:
|
||||||
|
return 4
|
||||||
|
if status_code == 404:
|
||||||
|
return 5
|
||||||
|
if status_code in (409, 412):
|
||||||
|
return 6
|
||||||
|
return 7
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response_body(response: requests.Response) -> Any:
|
||||||
|
if response.status_code == 204 or not response.content:
|
||||||
|
return {}
|
||||||
|
content_type = response.headers.get("content-type", "").lower()
|
||||||
|
if "application/json" in content_type:
|
||||||
|
payload = response.json()
|
||||||
|
if isinstance(payload, dict) and payload.get("status") == "error":
|
||||||
|
raise CLIError(
|
||||||
|
"服务端错误",
|
||||||
|
code="SERVER_ERROR",
|
||||||
|
message=str(payload.get("message") or "server returned error status"),
|
||||||
|
exit_code=7,
|
||||||
|
data=payload,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
text = response.text
|
||||||
|
if text:
|
||||||
|
return {"report": text}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json_body: Any = None,
|
||||||
|
require_auth: bool = True,
|
||||||
|
require_project: bool = False,
|
||||||
|
require_network_ctx: bool = False,
|
||||||
|
require_username_ctx: bool = False,
|
||||||
|
) -> tuple[Any, int]:
|
||||||
|
require_server(ctx)
|
||||||
|
if require_network_ctx:
|
||||||
|
require_network(ctx)
|
||||||
|
if require_username_ctx:
|
||||||
|
require_username(ctx)
|
||||||
|
|
||||||
|
url = f"{require_server(ctx)}/api/v1{path}"
|
||||||
|
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method=method.upper(),
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
json=json_body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=ctx.timeout,
|
||||||
|
)
|
||||||
|
except requests.Timeout as exc:
|
||||||
|
raise CLIError(
|
||||||
|
"请求超时",
|
||||||
|
code="REQUEST_TIMEOUT",
|
||||||
|
message=f"request timed out after {ctx.timeout} seconds",
|
||||||
|
exit_code=7,
|
||||||
|
retryable=True,
|
||||||
|
) from exc
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CLIError(
|
||||||
|
"连接失败",
|
||||||
|
code="REQUEST_FAILED",
|
||||||
|
message=str(exc),
|
||||||
|
exit_code=7,
|
||||||
|
retryable=True,
|
||||||
|
) from exc
|
||||||
|
duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
raise CLIError(
|
||||||
|
"请求失败",
|
||||||
|
code=f"HTTP_{response.status_code}",
|
||||||
|
message=_extract_error_message(response),
|
||||||
|
exit_code=map_http_status_to_exit_code(response.status_code),
|
||||||
|
retryable=response.status_code >= 500,
|
||||||
|
)
|
||||||
|
return _parse_response_body(response), duration_ms
|
||||||
|
|
||||||
|
|
||||||
|
def request_bytes(
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
require_auth: bool = True,
|
||||||
|
require_project: bool = False,
|
||||||
|
require_network_ctx: bool = False,
|
||||||
|
) -> tuple[bytes, int]:
|
||||||
|
require_server(ctx)
|
||||||
|
if require_network_ctx:
|
||||||
|
require_network(ctx)
|
||||||
|
|
||||||
|
url = f"{require_server(ctx)}/api/v1{path}"
|
||||||
|
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method=method.upper(),
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
timeout=ctx.timeout,
|
||||||
|
)
|
||||||
|
except requests.Timeout as exc:
|
||||||
|
raise CLIError(
|
||||||
|
"请求超时",
|
||||||
|
code="REQUEST_TIMEOUT",
|
||||||
|
message=f"request timed out after {ctx.timeout} seconds",
|
||||||
|
exit_code=7,
|
||||||
|
retryable=True,
|
||||||
|
) from exc
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CLIError(
|
||||||
|
"连接失败",
|
||||||
|
code="REQUEST_FAILED",
|
||||||
|
message=str(exc),
|
||||||
|
exit_code=7,
|
||||||
|
retryable=True,
|
||||||
|
) from exc
|
||||||
|
duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
raise CLIError(
|
||||||
|
"请求失败",
|
||||||
|
code=f"HTTP_{response.status_code}",
|
||||||
|
message=_extract_error_message(response),
|
||||||
|
exit_code=map_http_status_to_exit_code(response.status_code),
|
||||||
|
retryable=response.status_code >= 500,
|
||||||
|
)
|
||||||
|
return response.content, duration_ms
|
||||||
|
|
||||||
|
|
||||||
|
def build_success_payload(
|
||||||
|
*,
|
||||||
|
summary: str,
|
||||||
|
data: Any,
|
||||||
|
server: str | None,
|
||||||
|
request_id: str,
|
||||||
|
duration_ms: int,
|
||||||
|
next_commands: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary": summary,
|
||||||
|
"data": data,
|
||||||
|
"metadata": {
|
||||||
|
"request_id": request_id,
|
||||||
|
"server": server,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||||
|
},
|
||||||
|
"next_commands": next_commands or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_failure_payload(
|
||||||
|
*,
|
||||||
|
summary: str,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
retryable: bool,
|
||||||
|
server: str | None,
|
||||||
|
request_id: str | None,
|
||||||
|
next_commands: list[str] | None = None,
|
||||||
|
data: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary": summary,
|
||||||
|
"error": {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"retryable": retryable,
|
||||||
|
},
|
||||||
|
"data": data,
|
||||||
|
"metadata": {
|
||||||
|
"request_id": request_id,
|
||||||
|
"server": server,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||||
|
},
|
||||||
|
"next_commands": next_commands or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def emit_success(
|
||||||
|
*,
|
||||||
|
summary: str,
|
||||||
|
data: Any,
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
duration_ms: int,
|
||||||
|
next_commands: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
typer.echo(
|
||||||
|
json.dumps(
|
||||||
|
build_success_payload(
|
||||||
|
summary=summary,
|
||||||
|
data=data,
|
||||||
|
server=ctx.server,
|
||||||
|
request_id=ctx.request_id,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
next_commands=next_commands,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def emit_failure(
|
||||||
|
*,
|
||||||
|
summary: str,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
exit_code: int,
|
||||||
|
retryable: bool,
|
||||||
|
server: str | None,
|
||||||
|
request_id: str | None,
|
||||||
|
next_commands: list[str] | None = None,
|
||||||
|
data: Any = None,
|
||||||
|
) -> int:
|
||||||
|
typer.echo(
|
||||||
|
json.dumps(
|
||||||
|
build_failure_payload(
|
||||||
|
summary=summary,
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
retryable=retryable,
|
||||||
|
server=server,
|
||||||
|
request_id=request_id,
|
||||||
|
next_commands=next_commands,
|
||||||
|
data=data,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return exit_code
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
|
import typer.core
|
||||||
|
|
||||||
|
|
||||||
|
class TJWaterGroup(typer.core.TyperGroup):
|
||||||
|
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
||||||
|
super().format_help(ctx, formatter)
|
||||||
|
from .helping import build_group_help_appendix
|
||||||
|
|
||||||
|
appendix = build_group_help_appendix(ctx)
|
||||||
|
if appendix:
|
||||||
|
formatter.write_paragraph()
|
||||||
|
formatter.write_text(appendix)
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
import click
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .apps import GROUP_HELP_APPS, TOP_LEVEL_COMMANDS, app
|
||||||
|
from .core import CLIError
|
||||||
|
from .registry import (
|
||||||
|
get_command_doc,
|
||||||
|
get_group_summary,
|
||||||
|
has_subcommands,
|
||||||
|
is_hidden_path,
|
||||||
|
list_capabilities,
|
||||||
|
list_subcommands,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _click_root_command() -> click.Command:
|
||||||
|
# Must stay lazy: the click tree is only complete after command modules import.
|
||||||
|
return typer.main.get_command(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_command_path(tokens: list[str]) -> tuple[str, ...]:
|
||||||
|
while tokens and tokens[0] not in TOP_LEVEL_COMMANDS:
|
||||||
|
tokens = tokens[1:]
|
||||||
|
return tuple(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]:
|
||||||
|
if click_ctx is None:
|
||||||
|
return ()
|
||||||
|
return _normalize_command_path(click_ctx.command_path.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_click_context(path: tuple[str, ...]) -> click.Context | None:
|
||||||
|
root = _click_root_command()
|
||||||
|
ctx: click.Context = click.Context(root, info_name="tjwater-cli")
|
||||||
|
command: click.Command = root
|
||||||
|
for token in path:
|
||||||
|
if not isinstance(command, click.Group):
|
||||||
|
return None
|
||||||
|
next_command = command.commands.get(token)
|
||||||
|
if next_command is None:
|
||||||
|
return None
|
||||||
|
ctx = click.Context(next_command, info_name=token, parent=ctx)
|
||||||
|
command = next_command
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def build_usage(path: tuple[str, ...]) -> str | None:
|
||||||
|
ctx = _build_click_context(path)
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
parts = ["tjwater-cli", *path]
|
||||||
|
for parameter in ctx.command.params:
|
||||||
|
if not isinstance(parameter, click.Option):
|
||||||
|
continue
|
||||||
|
if "--help" in parameter.opts:
|
||||||
|
continue
|
||||||
|
option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||||
|
if parameter.is_flag:
|
||||||
|
parts.append(f"--{option_name}" if parameter.required else f"[--{option_name}]")
|
||||||
|
continue
|
||||||
|
placeholder = option_name.upper().replace("-", "_")
|
||||||
|
if parameter.required:
|
||||||
|
parts.extend([f"--{option_name}", f"<{placeholder}>"])
|
||||||
|
else:
|
||||||
|
parts.append(f"[--{option_name} <{placeholder}>]")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||||
|
ctx = _build_click_context(path)
|
||||||
|
if ctx is None:
|
||||||
|
return []
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
for parameter in ctx.command.params:
|
||||||
|
if not isinstance(parameter, click.Option):
|
||||||
|
continue
|
||||||
|
if "--help" in parameter.opts:
|
||||||
|
continue
|
||||||
|
cli_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"name": cli_name,
|
||||||
|
"description": parameter.help or "",
|
||||||
|
"required": parameter.required,
|
||||||
|
"repeated": parameter.multiple,
|
||||||
|
"default": parameter.default,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_option_value(path: tuple[str, ...], option_name: str) -> str:
|
||||||
|
path_specific_samples: dict[tuple[tuple[str, ...], str], str] = {
|
||||||
|
(("component", "option", "schema"), "kind"): "time",
|
||||||
|
(("component", "option", "get"), "kind"): "time",
|
||||||
|
(("data", "timeseries", "composite"), "kind"): "scada-simulation",
|
||||||
|
(("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)]
|
||||||
|
if option_name == "start-time":
|
||||||
|
return "2025-01-02T03:04:05+08:00"
|
||||||
|
if option_name == "end-time":
|
||||||
|
return "2025-01-02T04:04:05+08:00"
|
||||||
|
if option_name == "date":
|
||||||
|
return "2025-01-02"
|
||||||
|
if option_name == "duration":
|
||||||
|
return "30"
|
||||||
|
if option_name == "kind":
|
||||||
|
return "time"
|
||||||
|
if option_name == "mode":
|
||||||
|
return "close"
|
||||||
|
if option_name == "scheme":
|
||||||
|
return "baseline"
|
||||||
|
if option_name == "output":
|
||||||
|
return "./demo.inp" if "export-inp" in path else "./output.json"
|
||||||
|
if option_name == "pump":
|
||||||
|
return "PUMP-1"
|
||||||
|
if option_name == "node":
|
||||||
|
return "J1"
|
||||||
|
if option_name == "source-node":
|
||||||
|
return "J1"
|
||||||
|
if option_name == "drainage-node":
|
||||||
|
return "J2"
|
||||||
|
if option_name in {"link", "pipe", "pipe-id", "element-id", "element"}:
|
||||||
|
return "P1"
|
||||||
|
if option_name == "flow":
|
||||||
|
return "120.5"
|
||||||
|
if option_name == "concentration":
|
||||||
|
return "0.8"
|
||||||
|
if option_name == "device-id":
|
||||||
|
return "SCADA-001"
|
||||||
|
if option_name == "burst-file":
|
||||||
|
return "./burst.json"
|
||||||
|
if option_name == "valve-setting-file":
|
||||||
|
return "./valves.json"
|
||||||
|
if option_name.endswith("-file"):
|
||||||
|
return "./input.json"
|
||||||
|
if option_name.endswith("-id"):
|
||||||
|
return "demo-id"
|
||||||
|
return "demo"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None = None) -> str:
|
||||||
|
ctx = _build_click_context(path)
|
||||||
|
required_option_names: list[str] = []
|
||||||
|
if ctx is not None:
|
||||||
|
required_option_names = [
|
||||||
|
next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||||
|
for parameter in ctx.command.params
|
||||||
|
if isinstance(parameter, click.Option) and "--help" not in parameter.opts and parameter.required
|
||||||
|
]
|
||||||
|
if existing_examples:
|
||||||
|
for example in existing_examples:
|
||||||
|
has_required_options = all(f"--{option_name}" in example for option_name in required_option_names)
|
||||||
|
if has_required_options:
|
||||||
|
return example
|
||||||
|
parts = ["tjwater-cli", *path]
|
||||||
|
if ctx is None:
|
||||||
|
return " ".join(parts)
|
||||||
|
for parameter in ctx.command.params:
|
||||||
|
if not isinstance(parameter, click.Option):
|
||||||
|
continue
|
||||||
|
if "--help" in parameter.opts or not parameter.required:
|
||||||
|
continue
|
||||||
|
option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||||
|
parts.extend([f"--{option_name}", _sample_option_value(path, option_name)])
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_leaf_payload(payload: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]:
|
||||||
|
enriched = dict(payload)
|
||||||
|
enriched["usage"] = build_usage(path) or payload.get("usage")
|
||||||
|
click_options = _click_option_docs(path)
|
||||||
|
if click_options:
|
||||||
|
enriched["options"] = click_options
|
||||||
|
enriched["examples"] = payload.get("examples") or []
|
||||||
|
if not enriched["examples"] or all("<" in example and ">" in example for example in enriched["examples"]):
|
||||||
|
enriched["examples"] = [_build_example(path, existing_examples=enriched["examples"])]
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
enriched = dict(payload)
|
||||||
|
commands: list[dict[str, Any]] = []
|
||||||
|
for command in payload.get("commands", []):
|
||||||
|
command_item = dict(command)
|
||||||
|
path = tuple(command_item["command"].split())
|
||||||
|
doc = get_command_doc(path)
|
||||||
|
if doc is None and has_subcommands(path):
|
||||||
|
command_item["usage"] = f"tjwater-cli {' '.join(path)} help"
|
||||||
|
command_item["example"] = f"tjwater-cli {' '.join(path)} help"
|
||||||
|
else:
|
||||||
|
existing_examples = [] if doc is None else list(doc.get("examples", []))
|
||||||
|
command_item["usage"] = build_usage(path) or command_item.get("usage")
|
||||||
|
command_item["example"] = _build_example(path, existing_examples=existing_examples)
|
||||||
|
commands.append(command_item)
|
||||||
|
enriched["commands"] = commands
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, bool]:
|
||||||
|
if not path:
|
||||||
|
return list_capabilities(), True
|
||||||
|
payload = get_command_doc(path)
|
||||||
|
if payload is not None:
|
||||||
|
return _enrich_leaf_payload(payload, path), False
|
||||||
|
if has_subcommands(path):
|
||||||
|
return _enrich_index_payload(list_subcommands(path, get_group_summary(path))), True
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def emit_help_payload(payload: dict[str, Any]) -> None:
|
||||||
|
typer.echo(json.dumps(payload, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def merge_next_commands(*groups: list[str] | None) -> list[str]:
|
||||||
|
merged: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for group in groups:
|
||||||
|
for command in group or []:
|
||||||
|
if command in seen:
|
||||||
|
continue
|
||||||
|
seen.add(command)
|
||||||
|
merged.append(command)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def merge_error_data(primary: Any, secondary: Any) -> Any:
|
||||||
|
if primary is None:
|
||||||
|
return secondary
|
||||||
|
if secondary is None:
|
||||||
|
return primary
|
||||||
|
if isinstance(primary, dict) and isinstance(secondary, dict):
|
||||||
|
return {**secondary, **primary}
|
||||||
|
return primary
|
||||||
|
|
||||||
|
|
||||||
|
def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str]]:
|
||||||
|
command_path = context_command_path(click_ctx)
|
||||||
|
usage = build_usage(command_path) if command_path else None
|
||||||
|
if command_path:
|
||||||
|
if command_path[-1] == "help":
|
||||||
|
group_path = command_path[:-1]
|
||||||
|
if group_path:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"command_group": " ".join(group_path),
|
||||||
|
"usage": f"tjwater-cli {' '.join(group_path)} help",
|
||||||
|
"examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
|
||||||
|
},
|
||||||
|
merge_next_commands(
|
||||||
|
[f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
|
||||||
|
["tjwater-cli help"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
payload, is_index = resolve_help_payload(command_path)
|
||||||
|
if payload is not None and not is_index:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"command": payload["command"],
|
||||||
|
"usage": payload.get("usage") or usage,
|
||||||
|
"examples": payload.get("examples", []),
|
||||||
|
},
|
||||||
|
merge_next_commands([f"tjwater-cli help {' '.join(command_path)}"], ["tjwater-cli help"]),
|
||||||
|
)
|
||||||
|
if payload is not None and is_index:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"command_group": " ".join(command_path),
|
||||||
|
"usage": f"tjwater-cli {' '.join(command_path)} help",
|
||||||
|
"examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
|
||||||
|
},
|
||||||
|
merge_next_commands(
|
||||||
|
[f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
|
||||||
|
["tjwater-cli help"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ({"usage": usage} if usage else None, ["tjwater-cli help"])
|
||||||
|
|
||||||
|
|
||||||
|
def classify_click_error(exc: click.ClickException) -> tuple[str, str]:
|
||||||
|
if isinstance(exc, click.NoSuchOption):
|
||||||
|
return "未知选项", "UNKNOWN_OPTION"
|
||||||
|
if isinstance(exc, click.MissingParameter):
|
||||||
|
return "缺少参数", "MISSING_PARAMETER"
|
||||||
|
if isinstance(exc, click.BadParameter):
|
||||||
|
return "参数无效", "INVALID_PARAMETER"
|
||||||
|
message = exc.format_message()
|
||||||
|
if "No such command" in message:
|
||||||
|
return "未找到命令", "COMMAND_NOT_FOUND"
|
||||||
|
return "CLI 参数错误", "USAGE_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_root_help_epilog() -> str:
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
"\b",
|
||||||
|
"Examples:",
|
||||||
|
" tjwater-cli help",
|
||||||
|
" tjwater-cli help simulation run",
|
||||||
|
" tjwater-cli simulation run --help",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
|
||||||
|
lines = ["\b"]
|
||||||
|
description = payload.get("description")
|
||||||
|
usage = payload.get("usage")
|
||||||
|
examples = payload.get("examples", [])
|
||||||
|
next_commands = payload.get("next_commands", [])
|
||||||
|
if description:
|
||||||
|
lines.extend([f"Description: {description}", ""])
|
||||||
|
if usage:
|
||||||
|
lines.extend([f"Usage example: {usage}", ""])
|
||||||
|
if examples:
|
||||||
|
lines.append("Examples:")
|
||||||
|
lines.extend(f" {example}" for example in examples)
|
||||||
|
lines.append("")
|
||||||
|
if next_commands:
|
||||||
|
lines.append("Next steps:")
|
||||||
|
lines.extend(f" {command}" for command in next_commands)
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
|
||||||
|
lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"]
|
||||||
|
for command in payload.get("commands", [])[:2]:
|
||||||
|
example = command.get("example")
|
||||||
|
if example:
|
||||||
|
lines.append(f" {example}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_group_help_appendix(click_ctx: click.Context | None) -> str | None:
|
||||||
|
path = context_command_path(click_ctx)
|
||||||
|
if not path:
|
||||||
|
return _build_root_help_epilog()
|
||||||
|
payload, is_index = resolve_help_payload(path)
|
||||||
|
if payload is None or not is_index:
|
||||||
|
return None
|
||||||
|
return _build_group_help_epilog(path, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def make_group_help_handler(path_prefix: tuple[str, ...]):
|
||||||
|
def group_help() -> None:
|
||||||
|
payload, is_index = resolve_help_payload(path_prefix)
|
||||||
|
if payload is None:
|
||||||
|
raise CLIError(
|
||||||
|
"未找到命令",
|
||||||
|
code="COMMAND_NOT_FOUND",
|
||||||
|
message=f"unknown command path: {' '.join(path_prefix)}",
|
||||||
|
exit_code=2,
|
||||||
|
next_commands=["tjwater-cli help"],
|
||||||
|
)
|
||||||
|
emit_help_payload(payload)
|
||||||
|
|
||||||
|
group_help.__name__ = f"{'_'.join(path_prefix)}_help"
|
||||||
|
return group_help
|
||||||
|
|
||||||
|
|
||||||
|
def register_group_help_commands() -> None:
|
||||||
|
for group_app, path_prefix in GROUP_HELP_APPS:
|
||||||
|
group_app.command("help")(make_group_help_handler(path_prefix))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_typer_help_metadata() -> None:
|
||||||
|
app.help = "\n".join(
|
||||||
|
[
|
||||||
|
"TJWater agent CLI",
|
||||||
|
"",
|
||||||
|
"Examples:",
|
||||||
|
" tjwater-cli help",
|
||||||
|
" tjwater-cli help simulation run",
|
||||||
|
" tjwater-cli simulation run --help",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.short_help = "TJWater agent CLI"
|
||||||
|
for group_app, path_prefix in GROUP_HELP_APPS:
|
||||||
|
for command_info in group_app.registered_commands:
|
||||||
|
command_path = (*path_prefix, command_info.name)
|
||||||
|
if command_info.name == "help":
|
||||||
|
command_info.help = f"输出 {' '.join(path_prefix)} 的 JSON 帮助信息。"
|
||||||
|
command_info.short_help = command_info.help
|
||||||
|
command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"])
|
||||||
|
command_info.hidden = False
|
||||||
|
continue
|
||||||
|
payload = get_command_doc(command_path)
|
||||||
|
command_info.help = None if payload is None else str(payload.get("summary", ""))
|
||||||
|
command_info.short_help = command_info.help
|
||||||
|
command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload)
|
||||||
|
command_info.hidden = is_hidden_path(command_path)
|
||||||
|
for group_info in group_app.registered_groups:
|
||||||
|
group_path = (*path_prefix, group_info.name)
|
||||||
|
summary = get_group_summary(group_path)
|
||||||
|
group_info.help = summary
|
||||||
|
group_info.short_help = summary
|
||||||
|
group_info.hidden = is_hidden_path(group_path)
|
||||||
|
for group_info in app.registered_groups:
|
||||||
|
group_path = (group_info.name,)
|
||||||
|
summary = get_group_summary(group_path)
|
||||||
|
group_info.help = summary
|
||||||
|
group_info.short_help = summary
|
||||||
|
group_info.hidden = is_hidden_path(group_path)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import click
|
||||||
|
import typer
|
||||||
|
from click.exceptions import NoArgsIsHelpError
|
||||||
|
|
||||||
|
from . import commands_analysis, commands_data, commands_readonly # noqa: F401
|
||||||
|
from .apps import app
|
||||||
|
from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure
|
||||||
|
from .helping import (
|
||||||
|
apply_typer_help_metadata,
|
||||||
|
build_error_guidance,
|
||||||
|
classify_click_error,
|
||||||
|
emit_help_payload,
|
||||||
|
merge_error_data,
|
||||||
|
merge_next_commands,
|
||||||
|
register_group_help_commands,
|
||||||
|
resolve_help_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback()
|
||||||
|
def root_callback(
|
||||||
|
ctx: typer.Context,
|
||||||
|
server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None,
|
||||||
|
auth_stdin: Annotated[bool, typer.Option("--auth-stdin", help="从标准输入读取认证上下文 JSON")] = False,
|
||||||
|
scheme: Annotated[str | None, typer.Option("--scheme", help="全局方案标识")] = None,
|
||||||
|
timeout: Annotated[int, typer.Option("--timeout", help="请求超时秒数")] = DEFAULT_TIMEOUT,
|
||||||
|
request_id: Annotated[str | None, typer.Option("--request-id", help="显式请求 ID")] = None,
|
||||||
|
) -> None:
|
||||||
|
ctx.obj = {
|
||||||
|
"server": server,
|
||||||
|
"auth_stdin": auth_stdin,
|
||||||
|
"scheme": scheme,
|
||||||
|
"timeout": timeout,
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_group_help_commands()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("help", context_settings={"allow_extra_args": True})
|
||||||
|
def help_command(ctx: typer.Context) -> None:
|
||||||
|
command_path = list(ctx.args)
|
||||||
|
payload, is_index = resolve_help_payload(tuple(command_path))
|
||||||
|
if payload is None:
|
||||||
|
emit_failure(
|
||||||
|
summary="未找到命令",
|
||||||
|
code="COMMAND_NOT_FOUND",
|
||||||
|
message=f"unknown command path: {' '.join(command_path)}",
|
||||||
|
exit_code=2,
|
||||||
|
retryable=False,
|
||||||
|
server=None,
|
||||||
|
request_id=None,
|
||||||
|
data={
|
||||||
|
"usage": "tjwater-cli help <command-path>",
|
||||||
|
"examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"],
|
||||||
|
},
|
||||||
|
next_commands=["tjwater-cli help", "tjwater-cli help simulation"],
|
||||||
|
)
|
||||||
|
raise typer.Exit(code=2)
|
||||||
|
emit_help_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
# Must run at import time because tests call runner.invoke(app, ...) directly.
|
||||||
|
apply_typer_help_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
try:
|
||||||
|
app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater-cli", standalone_mode=False)
|
||||||
|
return 0
|
||||||
|
except CLIError as exc:
|
||||||
|
click_ctx = click.get_current_context(silent=True)
|
||||||
|
error_data, next_commands = build_error_guidance(click_ctx)
|
||||||
|
return emit_failure(
|
||||||
|
summary=exc.summary,
|
||||||
|
code=exc.code,
|
||||||
|
message=exc.message,
|
||||||
|
exit_code=exc.exit_code,
|
||||||
|
retryable=exc.retryable,
|
||||||
|
server=None,
|
||||||
|
request_id=None,
|
||||||
|
next_commands=merge_next_commands(exc.next_commands, next_commands),
|
||||||
|
data=merge_error_data(exc.data, error_data),
|
||||||
|
)
|
||||||
|
except NoArgsIsHelpError:
|
||||||
|
return 0
|
||||||
|
except click.ClickException as exc:
|
||||||
|
click_ctx = click.get_current_context(silent=True) or exc.ctx
|
||||||
|
error_data, next_commands = build_error_guidance(click_ctx)
|
||||||
|
summary, code = classify_click_error(exc)
|
||||||
|
return emit_failure(
|
||||||
|
summary=summary,
|
||||||
|
code=code,
|
||||||
|
message=exc.format_message(),
|
||||||
|
exit_code=2,
|
||||||
|
retryable=False,
|
||||||
|
server=None,
|
||||||
|
request_id=None,
|
||||||
|
next_commands=next_commands,
|
||||||
|
data=error_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def console_entry() -> None:
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION
|
||||||
|
|
||||||
|
GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
|
||||||
|
("network",): "管网节点、管线等基础属性查询命令。",
|
||||||
|
("component",): "组件选项与配置读取命令。",
|
||||||
|
("component", "option"): "组件选项查询命令。",
|
||||||
|
("simulation",): "模拟运行与调度相关命令。",
|
||||||
|
("analysis",): "分析计算与诊断相关命令。",
|
||||||
|
("analysis", "leakage"): "漏损分析相关命令。",
|
||||||
|
("analysis", "leakage", "schemes"): "漏损方案查询命令。",
|
||||||
|
("analysis", "burst-detection"): "爆管检测相关命令。",
|
||||||
|
("analysis", "burst-detection", "schemes"): "爆管检测方案查询命令。",
|
||||||
|
("analysis", "burst-location"): "爆管定位相关命令。",
|
||||||
|
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
|
||||||
|
("analysis", "risk"): "风险分析相关命令。",
|
||||||
|
("analysis", "sensor-placement"): "传感器选址相关命令。",
|
||||||
|
("data",): "时序、SCADA 和方案数据查询命令。",
|
||||||
|
("data", "timeseries"): "时序数据查询命令。",
|
||||||
|
("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
|
||||||
|
("data", "timeseries", "scheme"): "方案时序查询命令。",
|
||||||
|
("data", "timeseries", "scada"): "SCADA 时序查询命令。",
|
||||||
|
("data", "timeseries", "composite"): "复合时序查询命令。",
|
||||||
|
("data", "scada"): "SCADA 元数据查询命令。",
|
||||||
|
("data", "scheme"): "方案数据查询命令。",
|
||||||
|
}
|
||||||
|
|
||||||
|
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||||
|
("analysis", "burst-location"),
|
||||||
|
("analysis", "risk"),
|
||||||
|
)
|
||||||
|
|
||||||
|
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||||
|
("network", "get-junction-properties"): CommandDoc(
|
||||||
|
path=("network", "get-junction-properties"),
|
||||||
|
summary="读取节点属性",
|
||||||
|
description="调用 /getjunctionproperties/。",
|
||||||
|
options=(CommandOptionDoc("junction", "节点 ID", required=True),),
|
||||||
|
examples=("tjwater-cli network get-junction-properties --junction J1",),
|
||||||
|
),
|
||||||
|
("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-pipes-properties"): CommandDoc(
|
||||||
|
path=("network", "get-all-pipes-properties"),
|
||||||
|
summary="读取全部管道属性",
|
||||||
|
description="调用 /getallpipeproperties/。",
|
||||||
|
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"),
|
||||||
|
summary="读取选项 schema",
|
||||||
|
description="kind 支持 time、energy、pump-energy、network。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("kind", "选项类型", required=True),
|
||||||
|
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
|
||||||
|
),
|
||||||
|
examples=(
|
||||||
|
"tjwater-cli component option schema --kind time",
|
||||||
|
"tjwater-cli component option schema --kind energy",
|
||||||
|
"tjwater-cli component option schema --kind pump-energy --pump PUMP1",
|
||||||
|
"tjwater-cli component option schema --kind network",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("component", "option", "get"): CommandDoc(
|
||||||
|
path=("component", "option", "get"),
|
||||||
|
summary="读取选项属性",
|
||||||
|
description="kind 支持 time、energy、pump-energy、network。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("kind", "选项类型", required=True),
|
||||||
|
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
|
||||||
|
),
|
||||||
|
examples=(
|
||||||
|
"tjwater-cli component option get --kind time",
|
||||||
|
"tjwater-cli component option get --kind energy",
|
||||||
|
"tjwater-cli component option get --kind pump-energy --pump PUMP1",
|
||||||
|
"tjwater-cli component option get --kind network",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("simulation", "run"): CommandDoc(
|
||||||
|
path=("simulation", "run"),
|
||||||
|
summary="触发指定绝对时间的模拟运行",
|
||||||
|
description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("duration", "持续分钟数", required=True),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30",),
|
||||||
|
next_commands=(
|
||||||
|
"tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
|
||||||
|
"tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
|
||||||
|
),
|
||||||
|
output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询",
|
||||||
|
),
|
||||||
|
("analysis", "burst"): CommandDoc(
|
||||||
|
path=("analysis", "burst"),
|
||||||
|
summary="执行爆管分析",
|
||||||
|
description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||||
|
CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
),
|
||||||
|
examples=(
|
||||||
|
"tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01",
|
||||||
|
"tjwater-cli data scheme get --name burst_case_01",
|
||||||
|
"tjwater-cli data scheme list",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("analysis", "valve"): CommandDoc(
|
||||||
|
path=("analysis", "valve"),
|
||||||
|
summary="阀门工况分析。",
|
||||||
|
description="close 模式按指定阀门关闭执行定时长模拟;isolation 模式按指定事故元素计算关阀隔离方案。duration 单位为秒。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'isolation'", required=True),
|
||||||
|
CommandOptionDoc(name="start-time", description="close 模式需要的起始绝对时间,必须显式带时区偏移"),
|
||||||
|
CommandOptionDoc(name="valve", description="close 模式下需关闭的阀门 ID(可多次指定)", repeated=True),
|
||||||
|
CommandOptionDoc(name="element", description="isolation 模式下的事故元素 ID(可多次指定)", repeated=True),
|
||||||
|
CommandOptionDoc(name="disabled-valve", description="isolation 模式下需排除的故障阀门 ID(可多次指定)", repeated=True),
|
||||||
|
CommandOptionDoc(name="duration", description="close 模式持续秒数,默认 900"),
|
||||||
|
CommandOptionDoc(name="scheme", description="close 模式方案名称"),
|
||||||
|
),
|
||||||
|
examples=(
|
||||||
|
"tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01",
|
||||||
|
"tjwater-cli analysis valve --mode isolation --element E1 --element E2",
|
||||||
|
"tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("analysis", "flushing"): CommandDoc(
|
||||||
|
path=("analysis", "flushing"),
|
||||||
|
summary="执行冲洗分析",
|
||||||
|
description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。duration 单位为秒,默认 900。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("valve-setting-file", "阀门开度 JSON 文件", required=True),
|
||||||
|
CommandOptionDoc("drainage-node", "排污节点 ID", required=True),
|
||||||
|
CommandOptionDoc("flow", "冲洗流量", required=True),
|
||||||
|
CommandOptionDoc("duration", "持续秒数,默认 900"),
|
||||||
|
CommandOptionDoc("scheme", "方案名称", required=True),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01",),
|
||||||
|
),
|
||||||
|
("analysis", "age"): CommandDoc(
|
||||||
|
path=("analysis", "age"),
|
||||||
|
summary="执行水龄分析",
|
||||||
|
description="调用 /age_analysis/。duration 单位为秒。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900",),
|
||||||
|
),
|
||||||
|
("analysis", "contaminant"): CommandDoc(
|
||||||
|
path=("analysis", "contaminant"),
|
||||||
|
summary="执行污染物模拟",
|
||||||
|
description="调用 /contaminant_simulation/。duration 单位为秒。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||||
|
CommandOptionDoc("source-node", "污染源节点 ID", required=True),
|
||||||
|
CommandOptionDoc("concentration", "浓度值", required=True),
|
||||||
|
CommandOptionDoc("pattern", "模式 ID"),
|
||||||
|
CommandOptionDoc("scheme", "方案名称", required=True),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01",),
|
||||||
|
),
|
||||||
|
("analysis", "sensor-placement", "kmeans"): CommandDoc(
|
||||||
|
path=("analysis", "sensor-placement", "kmeans"),
|
||||||
|
summary="执行 KMeans 传感器选址",
|
||||||
|
description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("count", "传感器数量", required=True),
|
||||||
|
CommandOptionDoc("min-diameter", "最小管径,默认 0"),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01",),
|
||||||
|
),
|
||||||
|
("analysis", "leakage", "identify"): CommandDoc(
|
||||||
|
path=("analysis", "leakage", "identify"),
|
||||||
|
summary="执行漏损识别",
|
||||||
|
description="把 CLI 时间映射到 scada_start / scada_end。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01",),
|
||||||
|
),
|
||||||
|
("analysis", "leakage", "schemes", "list"): CommandDoc(
|
||||||
|
path=("analysis", "leakage", "schemes", "list"),
|
||||||
|
summary="列出漏损方案",
|
||||||
|
description="调用 /leakage/schemes/。",
|
||||||
|
examples=("tjwater-cli analysis leakage schemes list",),
|
||||||
|
),
|
||||||
|
("analysis", "leakage", "schemes", "get"): CommandDoc(
|
||||||
|
path=("analysis", "leakage", "schemes", "get"),
|
||||||
|
summary="读取漏损方案详情",
|
||||||
|
description="调用 /leakage/schemes/{scheme_name}。",
|
||||||
|
examples=("tjwater-cli analysis leakage schemes get my_scheme",),
|
||||||
|
),
|
||||||
|
("analysis", "burst-detection", "detect"): CommandDoc(
|
||||||
|
path=("analysis", "burst-detection", "detect"),
|
||||||
|
summary="执行爆管检测",
|
||||||
|
description="调用 /burst-detection/detect/。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01",),
|
||||||
|
),
|
||||||
|
("analysis", "burst-detection", "schemes", "list"): CommandDoc(
|
||||||
|
path=("analysis", "burst-detection", "schemes", "list"),
|
||||||
|
summary="列出爆管检测方案",
|
||||||
|
description="调用 /burst-detection/schemes/。",
|
||||||
|
examples=("tjwater-cli analysis burst-detection schemes list",),
|
||||||
|
),
|
||||||
|
("analysis", "burst-detection", "schemes", "get"): CommandDoc(
|
||||||
|
path=("analysis", "burst-detection", "schemes", "get"),
|
||||||
|
summary="读取爆管检测方案详情",
|
||||||
|
description="调用 /burst-detection/schemes/{scheme_name}。",
|
||||||
|
examples=("tjwater-cli analysis burst-detection schemes get my_scheme",),
|
||||||
|
),
|
||||||
|
("analysis", "burst-location", "locate"): CommandDoc(
|
||||||
|
path=("analysis", "burst-location", "locate"),
|
||||||
|
summary="执行爆管定位",
|
||||||
|
description="调用 /burst-location/locate/;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||||
|
CommandOptionDoc("burst-leakage", "爆管漏水量", required=True),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
CommandOptionDoc("data-source", "数据源:monitoring(默认)或 simulation"),
|
||||||
|
CommandOptionDoc("pressure-scada-id", "压力 SCADA ID(可多次指定)", repeated=True),
|
||||||
|
CommandOptionDoc("flow-scada-id", "流量 SCADA ID(可多次指定)", repeated=True),
|
||||||
|
CommandOptionDoc("pressure-file", "包含 burst_pressure/normal_pressure 的 JSON 文件"),
|
||||||
|
CommandOptionDoc("flow-file", "包含 burst_flow/normal_flow 的 JSON 文件"),
|
||||||
|
CommandOptionDoc("use-scada-flow", "启用 SCADA 流量"),
|
||||||
|
),
|
||||||
|
examples=(
|
||||||
|
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01",
|
||||||
|
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 50.0 --scheme locate_case_01 --data-source simulation --pressure-file ./pressure.json --flow-file ./flow.json",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("analysis", "burst-location", "schemes", "list"): CommandDoc(
|
||||||
|
path=("analysis", "burst-location", "schemes", "list"),
|
||||||
|
summary="列出爆管定位方案",
|
||||||
|
description="调用 /burst-location/schemes/。",
|
||||||
|
examples=("tjwater-cli analysis burst-location schemes list",),
|
||||||
|
),
|
||||||
|
("analysis", "burst-location", "schemes", "get"): CommandDoc(
|
||||||
|
path=("analysis", "burst-location", "schemes", "get"),
|
||||||
|
summary="读取爆管定位方案详情",
|
||||||
|
description="调用 /burst-location/schemes/{scheme_name}。",
|
||||||
|
examples=("tjwater-cli analysis burst-location schemes get my_scheme",),
|
||||||
|
),
|
||||||
|
("analysis", "risk", "pipe-now"): CommandDoc(
|
||||||
|
path=("analysis", "risk", "pipe-now"),
|
||||||
|
summary="读取单条管道当前风险",
|
||||||
|
description="调用 /getpiperiskprobabilitynow/。",
|
||||||
|
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||||
|
examples=("tjwater-cli analysis risk pipe-now --pipe P1",),
|
||||||
|
),
|
||||||
|
("analysis", "risk", "pipe-history"): CommandDoc(
|
||||||
|
path=("analysis", "risk", "pipe-history"),
|
||||||
|
summary="读取单条管道历史风险",
|
||||||
|
description="调用 /getpiperiskprobability/。",
|
||||||
|
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||||
|
examples=("tjwater-cli analysis risk pipe-history --pipe P1",),
|
||||||
|
),
|
||||||
|
("analysis", "risk", "network"): CommandDoc(
|
||||||
|
path=("analysis", "risk", "network"),
|
||||||
|
summary="读取全网风险",
|
||||||
|
description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。",
|
||||||
|
examples=("tjwater-cli analysis risk network",),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "realtime", "links"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "realtime", "links"),
|
||||||
|
summary="查询实时管道时序",
|
||||||
|
description="调用 /realtime/links。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "realtime", "nodes"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "realtime", "nodes"),
|
||||||
|
summary="查询实时节点时序",
|
||||||
|
description="调用 /realtime/nodes。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "realtime", "simulation-by-id-time"),
|
||||||
|
summary="按元素和时间查询实时模拟结果",
|
||||||
|
description="调用 /realtime/query/by-id-time。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("id", "元素 ID", required=True),
|
||||||
|
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||||
|
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||||
|
),
|
||||||
|
examples=(
|
||||||
|
"tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00",
|
||||||
|
"tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
|
||||||
|
summary="按时间和属性查询实时模拟结果",
|
||||||
|
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;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||||
|
CommandOptionDoc("time", "显式带时区的查询时间", 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",),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "scheme", "links"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "scheme", "links"),
|
||||||
|
summary="查询方案管道时序",
|
||||||
|
description="调用 /scheme/links。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
CommandOptionDoc("scheme-type", "方案类型"),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "scheme", "node-field"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "scheme", "node-field"),
|
||||||
|
summary="查询方案节点字段时序",
|
||||||
|
description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("node", "节点 ID", required=True),
|
||||||
|
CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True),
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
CommandOptionDoc("scheme-type", "方案类型"),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "scheme", "simulation"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "scheme", "simulation"),
|
||||||
|
summary="查询方案模拟数据",
|
||||||
|
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;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",
|
||||||
|
"tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "scada", "query"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "scada", "query"),
|
||||||
|
summary="查询 SCADA 时序",
|
||||||
|
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", "字段名: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 monitored_value",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "composite"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "composite"),
|
||||||
|
summary="执行复合时序查询",
|
||||||
|
description="kind 支持 scada-simulation、element-simulation、element-scada。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("kind", "复合查询类型", required=True),
|
||||||
|
CommandOptionDoc("feature", "特征值(可多次指定,scada-simulation 为 device_id,element-simulation 为 element_id:property,element-scada 为 element_id)", repeated=True),
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
|
CommandOptionDoc("scheme-type", "方案类型"),
|
||||||
|
CommandOptionDoc("use-cleaned", "element-scada 使用清洗值"),
|
||||||
|
),
|
||||||
|
examples=(
|
||||||
|
"tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
|
||||||
|
"tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
|
||||||
|
"tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("data", "timeseries", "composite", "pipeline-health"): CommandDoc(
|
||||||
|
path=("data", "timeseries", "composite", "pipeline-health"),
|
||||||
|
summary="查询管道健康预测",
|
||||||
|
description="调用 /composite/pipeline-health-prediction。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("pipe", "管道 ID", required=True),
|
||||||
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
|
),
|
||||||
|
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", "get"): CommandDoc(
|
||||||
|
path=("data", "scada", "get"),
|
||||||
|
summary="读取单条 SCADA 元数据",
|
||||||
|
description="kind 仅支持 info。",
|
||||||
|
options=(
|
||||||
|
CommandOptionDoc("kind", "SCADA 数据类型", required=True),
|
||||||
|
CommandOptionDoc("id", "记录 ID", required=True),
|
||||||
|
),
|
||||||
|
examples=("tjwater-cli data scada get --kind info --id SCADA-001",),
|
||||||
|
),
|
||||||
|
("data", "scada", "list"): CommandDoc(
|
||||||
|
path=("data", "scada", "list"),
|
||||||
|
summary="列出 SCADA 元数据",
|
||||||
|
description="kind 仅支持 info。",
|
||||||
|
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
|
||||||
|
examples=("tjwater-cli data scada list --kind info",),
|
||||||
|
),
|
||||||
|
("data", "scheme", "schema"): CommandDoc(
|
||||||
|
path=("data", "scheme", "schema"),
|
||||||
|
summary="读取方案 schema",
|
||||||
|
description="调用 /getschemeschema/。",
|
||||||
|
examples=("tjwater-cli data scheme schema",),
|
||||||
|
),
|
||||||
|
("data", "scheme", "get"): CommandDoc(
|
||||||
|
path=("data", "scheme", "get"),
|
||||||
|
summary="读取单条方案",
|
||||||
|
description="调用 /getscheme/。",
|
||||||
|
options=(CommandOptionDoc("name", "方案名称", required=True),),
|
||||||
|
examples=("tjwater-cli data scheme get --name my_scheme",),
|
||||||
|
),
|
||||||
|
("data", "scheme", "list"): CommandDoc(
|
||||||
|
path=("data", "scheme", "list"),
|
||||||
|
summary="列出方案",
|
||||||
|
description="调用 /getallschemes/。",
|
||||||
|
examples=("tjwater-cli data scheme list",),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_examples(doc: CommandDoc) -> list[str]:
|
||||||
|
return list(doc.examples) if doc.examples else [_build_usage(doc)]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hidden_path(path: tuple[str, ...]) -> bool:
|
||||||
|
return any(path[: len(prefix)] == prefix for prefix in HIDDEN_PATH_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden_path(path: tuple[str, ...]) -> bool:
|
||||||
|
return _is_hidden_path(path)
|
||||||
|
|
||||||
|
|
||||||
|
def has_subcommands(path_prefix: tuple[str, ...]) -> bool:
|
||||||
|
return any(
|
||||||
|
not _is_hidden_path(doc.path)
|
||||||
|
and doc.path[: len(path_prefix)] == path_prefix
|
||||||
|
and len(doc.path) > len(path_prefix)
|
||||||
|
for doc in COMMAND_DOCS.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_group_summary(path_prefix: tuple[str, ...]) -> str:
|
||||||
|
return GROUP_SUMMARIES.get(path_prefix, f"{' '.join(path_prefix)} 可用子命令")
|
||||||
|
|
||||||
|
|
||||||
|
def list_capabilities() -> dict[str, object]:
|
||||||
|
seen: set[tuple[str, ...]] = set()
|
||||||
|
commands: list[dict[str, str]] = []
|
||||||
|
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
|
||||||
|
if _is_hidden_path(doc.path):
|
||||||
|
continue
|
||||||
|
prefix = doc.path[:1]
|
||||||
|
if prefix in seen:
|
||||||
|
continue
|
||||||
|
seen.add(prefix)
|
||||||
|
commands.append(
|
||||||
|
{
|
||||||
|
"command": " ".join(prefix),
|
||||||
|
"summary": get_group_summary(prefix),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary": "可用一级菜单",
|
||||||
|
"menu_level": 1,
|
||||||
|
"commands": commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_command_doc(path: tuple[str, ...]) -> dict[str, object] | None:
|
||||||
|
if _is_hidden_path(path):
|
||||||
|
return None
|
||||||
|
doc = COMMAND_DOCS.get(path)
|
||||||
|
if doc is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary": doc.summary,
|
||||||
|
"command": " ".join(doc.path),
|
||||||
|
"description": doc.description,
|
||||||
|
"usage": _build_usage(doc),
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"name": option.name,
|
||||||
|
"description": option.description,
|
||||||
|
"required": option.required,
|
||||||
|
"repeated": option.repeated,
|
||||||
|
"default": option.default,
|
||||||
|
}
|
||||||
|
for option in doc.options
|
||||||
|
],
|
||||||
|
"examples": _build_examples(doc),
|
||||||
|
"next_commands": list(doc.next_commands),
|
||||||
|
"output": doc.output,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -> dict[str, object]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
commands: list[dict[str, str]] = []
|
||||||
|
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
|
||||||
|
if _is_hidden_path(doc.path):
|
||||||
|
continue
|
||||||
|
if doc.path[: len(path_prefix)] != path_prefix or len(doc.path) <= len(path_prefix):
|
||||||
|
continue
|
||||||
|
subcommand = doc.path[len(path_prefix)]
|
||||||
|
if subcommand in seen:
|
||||||
|
continue
|
||||||
|
seen.add(subcommand)
|
||||||
|
current_path = (*path_prefix, subcommand)
|
||||||
|
is_group = has_subcommands(current_path)
|
||||||
|
usage = f"tjwater-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc))
|
||||||
|
commands.append(
|
||||||
|
{
|
||||||
|
"command": " ".join(current_path),
|
||||||
|
"summary": get_group_summary(current_path) if is_group else doc.summary,
|
||||||
|
"usage": usage,
|
||||||
|
"example": f"tjwater-cli {' '.join(current_path)} help" if is_group else _build_examples(doc)[0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary": summary or get_group_summary(path_prefix),
|
||||||
|
"commands": commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_usage(doc: CommandDoc) -> str:
|
||||||
|
parts = ["tjwater-cli", *doc.path]
|
||||||
|
for option in doc.options:
|
||||||
|
placeholder = option.name.upper().replace("-", "_")
|
||||||
|
if option.required:
|
||||||
|
parts.extend([f"--{option.name}", f"<{placeholder}>"])
|
||||||
|
else:
|
||||||
|
parts.append(f"[--{option.name} <{placeholder}>]")
|
||||||
|
return " ".join(parts)
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# Agent CLI 接口范围确认
|
||||||
|
|
||||||
|
本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。
|
||||||
|
|
||||||
|
```text
|
||||||
|
tjwater-cli network
|
||||||
|
tjwater-cli component
|
||||||
|
tjwater-cli simulation
|
||||||
|
tjwater-cli analysis
|
||||||
|
tjwater-cli data
|
||||||
|
tjwater-cli help
|
||||||
|
```
|
||||||
|
|
||||||
|
首批默认不暴露:
|
||||||
|
|
||||||
|
- 会修改 network 的接口:`add*`、`set*`、`delete*`、`generate*`
|
||||||
|
- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制
|
||||||
|
- 数据写入/清理接口:insert、update、delete、clean、clear、batch store
|
||||||
|
- 用户管理接口:创建、更新、删除、激活、停用
|
||||||
|
- 快照回滚和批量命令执行接口:undo、redo、pick、batch
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。
|
||||||
|
- 首批只暴露 `schema`、`list`、`get`、`exists`、只读计算和分析类能力。
|
||||||
|
- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。
|
||||||
|
- CLI 输出统一使用 JSON;首批默认直接在 stdout 返回结构化结果,不再额外设计 `result_ref` / `--out-ref` 输出层。
|
||||||
|
- 首批 CLI 只保留 **Non-interactive / Agent** 认证模式:必须显式注入认证上下文,不隐式复用本机默认登录态,也不设计本地 `login`。
|
||||||
|
- stdout/stderr、退出码、输出 schema version 视为 CLI 契约的一部分,需要独立于 HTTP body 明确定义。
|
||||||
|
- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。
|
||||||
|
- 高频命令可以提供 alias,但文档和 skill 只写规范命令。
|
||||||
|
|
||||||
|
## 分级约束
|
||||||
|
|
||||||
|
| 顶层命令 | 二级范围 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `network` | `get-node-properties`、`get-link-properties` | 管网节点/管线属性查询,只读 |
|
||||||
|
| `component` | `option` | EPANET 选项设置,只读 |
|
||||||
|
| `simulation` | `run` | 模拟运行 |
|
||||||
|
| `analysis` | `burst`、`valve`、`flushing`、`age`、`contaminant`、`sensor-placement`、`leakage`、`burst-detection`、`burst-location`、`risk` | 任务级分析 |
|
||||||
|
| `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 |
|
||||||
|
| `help` | `COMMAND` | Agent 能力发现和命令说明 |
|
||||||
|
|
||||||
|
命令深度建议:
|
||||||
|
|
||||||
|
- 常规命令不超过 3 层:`tjwater-cli component option get`
|
||||||
|
- 时序数据允许 4 层:`tjwater-cli data timeseries realtime links`
|
||||||
|
- `risk` 归入 `analysis risk`
|
||||||
|
- `scada`、`scheme`、`extension` 归入 `data`
|
||||||
|
|
||||||
|
## 全局上下文与通用参数
|
||||||
|
|
||||||
|
首批 CLI 建议统一支持以下全局参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--server URL
|
||||||
|
--auth-context PATH
|
||||||
|
--scheme SCHEME
|
||||||
|
--timeout SEC
|
||||||
|
--request-id ID
|
||||||
|
```
|
||||||
|
|
||||||
|
参数含义:
|
||||||
|
|
||||||
|
| 参数 | 含义 | 作用域 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--server URL` | 指定 CLI 要连接的服务端地址 | 连接上下文 | 例如 `https://api.example.com`。用于覆盖环境变量或 `auth-context` 中的默认 base URL,便于在 dev / test / prod 间切换。 |
|
||||||
|
| `--auth-context PATH` | 指定一份显式的隔离认证上下文文件 | 认证上下文 | 面向 agent / 自动化调用。该文件可包含 access token、server、project、user 等字段;不得隐式回退到本机默认状态。 |
|
||||||
|
| `--scheme SCHEME` | 指定当前命令使用的方案 / 工况 / 配置集标识 | 业务资源上下文 | 适用于时序方案、检测方案、定位方案等场景。用于区分当前 project 下的不同分析配置。 |
|
||||||
|
| `--timeout SEC` | 指定本次命令等待响应的超时时间 | 执行控制 | 对同步请求表示请求超时上限,超过后 CLI 直接返回超时错误。 |
|
||||||
|
| `--request-id ID` | 为本次调用显式指定链路追踪 ID | 追踪与观测 | 便于跨前端、CLI、服务端串联日志与审计记录。若未提供,CLI 可自动生成,并应在输出 metadata 中回显。 |
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- project 属于认证上下文的一部分,默认从 `auth-context` 或前端传入的 `X-Project-Id` 解析,不作为常规全局参数要求重复传入。
|
||||||
|
- 首批 CLI 不提供 Interactive / Human 登录态;所有命令都按 Agent 模式处理,不得依赖隐式默认认证状态。
|
||||||
|
- `--server`、`--auth-context` 属于连接与认证上下文;`--scheme` 属于业务资源上下文,两者需要分开建模。
|
||||||
|
- `--request-id` 用于链路追踪;若未显式传入,CLI 可以自动生成,但必须在输出 metadata 中回显。
|
||||||
|
|
||||||
|
参数表达建议:
|
||||||
|
|
||||||
|
- 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。
|
||||||
|
- 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。
|
||||||
|
- 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE`、`NODE:VALUE`、`VALVE:OPENING` 这类 shell 内联 DSL。
|
||||||
|
- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help` 给出 schema。
|
||||||
|
|
||||||
|
## 首批 CLI 范围
|
||||||
|
|
||||||
|
### Network
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/api/v1/endpoints/network/*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| 命令 | 覆盖接口 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 |
|
||||||
|
| `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 |
|
||||||
|
| `tjwater-cli network get-all-junction-properties` | `GET /getalljunctionproperties/` | 读取当前 project 中所有节点属性 |
|
||||||
|
| `tjwater-cli network get-all-pipe-properties` | `GET /getallpipeproperties/` | 读取当前 project 中所有管道属性 |
|
||||||
|
|
||||||
|
暂不暴露:
|
||||||
|
|
||||||
|
```text
|
||||||
|
add*
|
||||||
|
set*
|
||||||
|
delete*
|
||||||
|
generate*
|
||||||
|
POST /generatedistrictmeteringarea/
|
||||||
|
POST /generatesubdistrictmeteringarea/
|
||||||
|
POST /generateservicearea/
|
||||||
|
POST /generatevirtualdistrict/
|
||||||
|
```
|
||||||
|
|
||||||
|
备注:`GET /settitle/` 语义是修改标题,首批不暴露。
|
||||||
|
|
||||||
|
### Component
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/api/v1/endpoints/components/*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| 命令 | 覆盖接口 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tjwater-cli component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema |
|
||||||
|
| `tjwater-cli component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 |
|
||||||
|
| `tjwater-cli component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema |
|
||||||
|
| `tjwater-cli component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 |
|
||||||
|
| `tjwater-cli component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema |
|
||||||
|
| `tjwater-cli component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 |
|
||||||
|
| `tjwater-cli component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema |
|
||||||
|
| `tjwater-cli component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 |
|
||||||
|
|
||||||
|
暂不暴露:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /addcurve/
|
||||||
|
POST /setcurveproperties/
|
||||||
|
POST /deletecurve/
|
||||||
|
POST /addpattern/
|
||||||
|
POST /setpatternproperties/
|
||||||
|
POST /deletepattern/
|
||||||
|
POST /settimeproperties/
|
||||||
|
POST /setenergyproperties/
|
||||||
|
GET /setpumpenergyproperties//
|
||||||
|
POST /setoptionproperties/
|
||||||
|
POST /setcontrolproperties/
|
||||||
|
POST /setruleproperties/
|
||||||
|
POST /setqualityproperties/
|
||||||
|
POST /setemitterproperties/
|
||||||
|
POST /setsource/
|
||||||
|
POST /addsource/
|
||||||
|
POST /deletesource/
|
||||||
|
POST /setreaction/
|
||||||
|
POST /setpipereaction/
|
||||||
|
POST /settankreaction/
|
||||||
|
POST /setmixing/
|
||||||
|
POST /addmixing/
|
||||||
|
POST /deletemixing/
|
||||||
|
POST /setvertexproperties/
|
||||||
|
POST /addvertex/
|
||||||
|
POST /deletevertex/
|
||||||
|
POST /setlabelproperties/
|
||||||
|
POST /addlabel/
|
||||||
|
POST /deletelabel/
|
||||||
|
POST /setbackdropproperties/
|
||||||
|
```
|
||||||
|
|
||||||
|
备注:
|
||||||
|
|
||||||
|
- `options` 当前实际只读接口分为 4 组:`time`、`energy`、`pump-energy`、`network`。
|
||||||
|
- `pump-energy` 是唯一需要额外资源标识的读取接口,必须带 `--pump PUMP`。
|
||||||
|
- 后端现有路径 `GET /getpumpenergyproperties//` 和 `GET /setpumpenergyproperties//` 存在双斜杠 / 方法异常,CLI 不继承这些路径细节,只保留语义化命令。
|
||||||
|
|
||||||
|
### Simulation / Analysis / Risk
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/api/v1/endpoints/simulation.py
|
||||||
|
app/api/v1/endpoints/leakage.py
|
||||||
|
app/api/v1/endpoints/burst_detection.py
|
||||||
|
app/api/v1/endpoints/burst_location.py
|
||||||
|
app/api/v1/endpoints/risk.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| 命令 | 覆盖接口 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 |
|
||||||
|
| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` |
|
||||||
|
| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 |
|
||||||
|
| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` |
|
||||||
|
| `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 |
|
||||||
|
| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant_simulation/` | 污染物模拟 |
|
||||||
|
| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 |
|
||||||
|
| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 |
|
||||||
|
| `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 |
|
||||||
|
| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 |
|
||||||
|
| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 |
|
||||||
|
| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 |
|
||||||
|
| `tjwater-cli analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 |
|
||||||
|
| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 |
|
||||||
|
| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 |
|
||||||
|
| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 |
|
||||||
|
|
||||||
|
暂缓或暂不暴露:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /network_project/
|
||||||
|
GET /runproject/
|
||||||
|
POST /network_update/
|
||||||
|
POST /project_management/
|
||||||
|
POST /sensorplacementscheme/create
|
||||||
|
POST /pump_failure/
|
||||||
|
POST /pressure_regulation/
|
||||||
|
POST /scheduling_analysis/
|
||||||
|
POST /daily_scheduling_analysis/
|
||||||
|
```
|
||||||
|
|
||||||
|
执行模型:
|
||||||
|
|
||||||
|
- 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。
|
||||||
|
- `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。
|
||||||
|
- 当前 `runsimulationmanuallybydate` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。
|
||||||
|
- `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。
|
||||||
|
- 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`。
|
||||||
|
- `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/api/v1/endpoints/timeseries/*.py
|
||||||
|
app/api/v1/endpoints/scada.py
|
||||||
|
app/api/v1/endpoints/schemes.py
|
||||||
|
app/api/v1/endpoints/extension.py
|
||||||
|
app/api/v1/endpoints/misc.py
|
||||||
|
app/api/v1/endpoints/project_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| 命令 | 覆盖接口 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tjwater-cli data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 |
|
||||||
|
| `tjwater-cli data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 |
|
||||||
|
| `tjwater-cli data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 |
|
||||||
|
| `tjwater-cli data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 |
|
||||||
|
| `tjwater-cli data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 |
|
||||||
|
| `tjwater-cli data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 |
|
||||||
|
| `tjwater-cli data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 |
|
||||||
|
| `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 get\|list --kind info` | `GET /getscadainfo/`、`GET /getallscadainfo/` | `SCADA info` 元数据 |
|
||||||
|
| `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 |
|
||||||
|
|
||||||
|
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
|
||||||
|
- `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
|
||||||
|
|
||||||
|
暂不暴露:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /realtime/*/batch
|
||||||
|
DELETE /realtime/*
|
||||||
|
PATCH /realtime/*
|
||||||
|
POST /realtime/simulation/store
|
||||||
|
POST /scheme/*/batch
|
||||||
|
PATCH /scheme/*
|
||||||
|
DELETE /scheme/*
|
||||||
|
POST /scheme/simulation/store
|
||||||
|
POST /scada/batch
|
||||||
|
PATCH /scada/{device_id}/field
|
||||||
|
DELETE /scada/by-id-time-range
|
||||||
|
POST /composite/clean-scada
|
||||||
|
POST /setscadadevice/
|
||||||
|
POST /addscadadevice/
|
||||||
|
POST /deletescadadevice/
|
||||||
|
POST /cleanscadadevice/
|
||||||
|
POST /setscadadevicedata/
|
||||||
|
POST /addscadadevicedata/
|
||||||
|
POST /deletescadadevicedata/
|
||||||
|
POST /cleanscadadevicedata/
|
||||||
|
POST /setscadaelement/
|
||||||
|
POST /addscadaelement/
|
||||||
|
POST /deletescadaelement/
|
||||||
|
POST /cleanscadaelement/
|
||||||
|
POST /setextensiondata/
|
||||||
|
POST /test_dict/
|
||||||
|
GET /getjson/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 不纳入首批 CLI 的运维接口
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/api/v1/endpoints/snapshots.py
|
||||||
|
app/api/v1/endpoints/cache.py
|
||||||
|
app/api/v1/endpoints/audit.py
|
||||||
|
app/api/v1/endpoints/users.py
|
||||||
|
app/api/v1/endpoints/user_management.py
|
||||||
|
```
|
||||||
|
|
||||||
|
这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计、用户管理或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。
|
||||||
|
|
||||||
|
暂不暴露:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /getcurrentoperationid/
|
||||||
|
GET /getsnapshots/
|
||||||
|
GET /havesnapshot/
|
||||||
|
GET /havesnapshotforoperation/
|
||||||
|
GET /havesnapshotforcurrentoperation/
|
||||||
|
GET /getrestoreoperation/
|
||||||
|
POST /undo/
|
||||||
|
POST /redo/
|
||||||
|
POST /takesnapshot*/
|
||||||
|
POST /picksnapshot/
|
||||||
|
POST /pickoperation/
|
||||||
|
GET /syncwithserver/
|
||||||
|
POST /batch/
|
||||||
|
POST /compressedbatch/
|
||||||
|
POST /setrestoreoperation/
|
||||||
|
GET /queryredis/
|
||||||
|
POST /clearrediskey/
|
||||||
|
POST /clearrediskeys/
|
||||||
|
POST /clearallredis/
|
||||||
|
GET /audit/logs
|
||||||
|
GET /audit/logs/my
|
||||||
|
GET /audit/logs/count
|
||||||
|
GET /getuserschema/
|
||||||
|
GET /getuser/
|
||||||
|
GET /getallusers/
|
||||||
|
PUT /users/{user_id}
|
||||||
|
DELETE /users/{user_id}
|
||||||
|
POST /users/{user_id}/activate
|
||||||
|
POST /users/{user_id}/deactivate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
`help` 不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `tjwater-cli help` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 |
|
||||||
|
| `tjwater-cli help COMMAND` | 返回某个命令的参数、输出、示例和推荐后续命令 |
|
||||||
|
|
||||||
|
输出补充约束:
|
||||||
|
|
||||||
|
- 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。
|
||||||
|
- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`。
|
||||||
|
- 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。
|
||||||
|
|
||||||
|
## 输出规范
|
||||||
|
|
||||||
|
进程级契约:
|
||||||
|
|
||||||
|
- `stdout`:默认只输出一个 JSON 对象,供 agent / 脚本稳定解析。
|
||||||
|
- `stderr`:输出进度、警告和诊断信息;不得混入结构化结果 JSON。
|
||||||
|
- 退出码必须稳定,不能简单透传底层 HTTP status。
|
||||||
|
|
||||||
|
建议退出码:
|
||||||
|
|
||||||
|
| 退出码 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0` | 成功 |
|
||||||
|
| `2` | CLI 参数错误 / 用法错误 |
|
||||||
|
| `3` | 认证失败 |
|
||||||
|
| `4` | 权限不足 |
|
||||||
|
| `5` | 资源不存在 |
|
||||||
|
| `6` | 冲突、前置条件不满足或非法状态 |
|
||||||
|
| `7` | 服务端错误 |
|
||||||
|
|
||||||
|
成功:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"schema_version": "tjwater-cli/v1",
|
||||||
|
"summary": "读取成功",
|
||||||
|
"data": {},
|
||||||
|
"metadata": {},
|
||||||
|
"next_commands": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"schema_version": "tjwater-cli/v1",
|
||||||
|
"summary": "认证失败",
|
||||||
|
"error": {
|
||||||
|
"code": "UNAUTHENTICATED",
|
||||||
|
"message": "missing access token for agent context",
|
||||||
|
"retryable": false
|
||||||
|
},
|
||||||
|
"data": null,
|
||||||
|
"metadata": {},
|
||||||
|
"next_commands": [
|
||||||
|
"tjwater-cli <command> --auth-context /path/to/auth-context.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
补充约束:
|
||||||
|
|
||||||
|
- `metadata` 至少建议包含:`request_id`、`server`、`duration_ms`、`generated_at`。
|
||||||
|
- `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。
|
||||||
|
- 所有 `help` 输出也应带 `schema_version`,便于 agent 做能力协商。
|
||||||
|
|
||||||
|
## 后续开放条件
|
||||||
|
|
||||||
|
如后续要开放写操作,需要单独设计:
|
||||||
|
|
||||||
|
- 权限校验
|
||||||
|
- dry-run / preview
|
||||||
|
- 显式确认机制
|
||||||
|
- 审计日志
|
||||||
|
- 变更快照
|
||||||
|
- 回滚策略
|
||||||
|
- Agent 可读的错误恢复建议
|
||||||
@@ -3,9 +3,10 @@ services:
|
|||||||
# Core API Service
|
# Core API Service
|
||||||
# ==========================================
|
# ==========================================
|
||||||
api:
|
api:
|
||||||
|
image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local}
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: infra/docker/Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tjwater_api
|
container_name: tjwater_api
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -168,3 +168,4 @@ zmq==0.0.0
|
|||||||
pymoo==0.6.1.6
|
pymoo==0.6.1.6
|
||||||
scikit-learn==1.6.1
|
scikit-learn==1.6.1
|
||||||
scipy==1.15.2
|
scipy==1.15.2
|
||||||
|
pyclipper==1.4.0
|
||||||
+19
-22
@@ -31,7 +31,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from starlette.responses import FileResponse, JSONResponse
|
from starlette.responses import FileResponse, JSONResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from multiprocessing import Value
|
from multiprocessing import Value
|
||||||
|
|
||||||
@@ -3654,40 +3654,35 @@ async def fastapi_download_history_data_manually(
|
|||||||
class Run_Simulation_Manually_by_Date(BaseModel):
|
class Run_Simulation_Manually_by_Date(BaseModel):
|
||||||
"""
|
"""
|
||||||
name:数据库名称
|
name:数据库名称
|
||||||
simulation_date:样式如 2025-05-04
|
start_time:开始时间,样式如 2025-05-04T08:00:00+08:00
|
||||||
start_time:开始时间,样式如 08:00:00
|
|
||||||
duration:持续时间,单位为分钟
|
duration:持续时间,单位为分钟
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
simulation_date: str
|
|
||||||
start_time: str
|
start_time: str
|
||||||
duration: int
|
duration: int
|
||||||
|
|
||||||
|
@field_validator("start_time")
|
||||||
|
@classmethod
|
||||||
|
def validate_start_time_timezone(cls, value: str) -> str:
|
||||||
|
time_api.parse_aware_time(value, field_name="start_time")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def run_simulation_manually_by_date(
|
def run_simulation_manually_by_date(
|
||||||
network_name: str, base_date: datetime, start_time: str, duration: int
|
network_name: str, start_time: datetime, duration: int
|
||||||
) -> None:
|
) -> None:
|
||||||
# 解析开始时间
|
|
||||||
start_hour, start_minute, start_second = map(int, start_time.split(":"))
|
|
||||||
start_datetime = base_date.replace(
|
|
||||||
hour=start_hour, minute=start_minute, second=start_second
|
|
||||||
)
|
|
||||||
|
|
||||||
# 计算结束时间
|
# 计算结束时间
|
||||||
end_datetime = start_datetime + timedelta(minutes=duration)
|
end_datetime = start_time + timedelta(minutes=duration)
|
||||||
|
|
||||||
# 生成时间点,每15分钟一个
|
# 生成时间点,每15分钟一个
|
||||||
current_time = start_datetime
|
current_time = start_time
|
||||||
while current_time < end_datetime:
|
while current_time < end_datetime:
|
||||||
# 格式化成ISO8601带时区格式
|
|
||||||
iso_time = current_time.strftime("%Y-%m-%dT%H:%M:%S") + "+08:00"
|
|
||||||
|
|
||||||
## 执行函数调用
|
## 执行函数调用
|
||||||
simulation.run_simulation(
|
simulation.run_simulation(
|
||||||
name=network_name,
|
name=network_name,
|
||||||
simulation_type="realtime",
|
simulation_type="realtime",
|
||||||
modify_pattern_start_time=iso_time,
|
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 增加15分钟
|
# 增加15分钟
|
||||||
@@ -3698,7 +3693,7 @@ def run_simulation_manually_by_date(
|
|||||||
async def fastapi_run_simulation_manually_by_date(
|
async def fastapi_run_simulation_manually_by_date(
|
||||||
data: Run_Simulation_Manually_by_Date,
|
data: Run_Simulation_Manually_by_Date,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
item = data.dict()
|
item = data.model_dump()
|
||||||
print(f"item: {item}")
|
print(f"item: {item}")
|
||||||
|
|
||||||
filename = "c:/lock.simulation"
|
filename = "c:/lock.simulation"
|
||||||
@@ -3740,11 +3735,13 @@ async def fastapi_run_simulation_manually_by_date(
|
|||||||
globals.realtime_region_pipe_flow_and_demand_id,
|
globals.realtime_region_pipe_flow_and_demand_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
base_date = datetime.strptime(item["simulation_date"], "%Y-%m-%d")
|
start_time = time_api.parse_utc_time(
|
||||||
|
item["start_time"], field_name="start_time"
|
||||||
|
)
|
||||||
|
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=lambda: run_simulation_manually_by_date(
|
target=lambda: run_simulation_manually_by_date(
|
||||||
item["name"], base_date, item["start_time"], item["duration"]
|
item["name"], start_time, item["duration"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3753,11 +3750,11 @@ async def fastapi_run_simulation_manually_by_date(
|
|||||||
|
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
|
|
||||||
# thread.join()
|
# thread.join()
|
||||||
# DingZQ 08152025
|
# DingZQ 08152025
|
||||||
# matched_keys = redis_client.keys(f"*{item['simulation_date']}*")
|
# matched_keys = redis_client.keys(...)
|
||||||
# redis_client.delete(*matched_keys)
|
# redis_client.delete(*matched_keys)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ def age_analysis(
|
|||||||
new_name,
|
new_name,
|
||||||
"realtime",
|
"realtime",
|
||||||
modify_pattern_start_time,
|
modify_pattern_start_time,
|
||||||
modify_total_duration,
|
duration=modify_total_duration,
|
||||||
downloading_prohibition=True,
|
downloading_prohibition=True,
|
||||||
)
|
)
|
||||||
# step 2. restore the base model status
|
# step 2. restore the base model status
|
||||||
|
|||||||
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,92 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from tests.conftest import build_test_app, install_stub, load_module_from_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_meta_module(monkeypatch):
|
||||||
|
install_stub(monkeypatch, "app.auth", package=True)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.auth.project_dependencies",
|
||||||
|
{
|
||||||
|
"ProjectContext": object,
|
||||||
|
"get_project_context": lambda: None,
|
||||||
|
"get_project_pg_session": lambda: None,
|
||||||
|
"get_project_timescale_connection": lambda: None,
|
||||||
|
"get_metadata_repository": lambda: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.auth.metadata_dependencies",
|
||||||
|
{"get_current_metadata_user": lambda: None},
|
||||||
|
)
|
||||||
|
return load_module_from_path(
|
||||||
|
"tests_meta_endpoints_module",
|
||||||
|
"app/api/v1/endpoints/meta.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_meta_project_returns_map_extent(monkeypatch):
|
||||||
|
module = _load_meta_module(monkeypatch)
|
||||||
|
project_id = uuid4()
|
||||||
|
repo = SimpleNamespace(
|
||||||
|
get_project_by_id=lambda _project_id: None,
|
||||||
|
get_geoserver_config=lambda _project_id: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_project_by_id(_project_id):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=project_id,
|
||||||
|
name="Demo Project",
|
||||||
|
code="demo",
|
||||||
|
description="desc",
|
||||||
|
gs_workspace="workspace",
|
||||||
|
map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4},
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_geoserver_config(_project_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
repo.get_project_by_id = get_project_by_id
|
||||||
|
repo.get_geoserver_config = get_geoserver_config
|
||||||
|
|
||||||
|
app = build_test_app(module.router, "/api/v1")
|
||||||
|
app.dependency_overrides[module.get_project_context] = lambda: SimpleNamespace(
|
||||||
|
project_id=project_id,
|
||||||
|
project_role="editor",
|
||||||
|
)
|
||||||
|
app.dependency_overrides[module.get_metadata_repository] = lambda: repo
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/api/v1/meta/project")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["map_extent"] == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch):
|
||||||
|
module = _load_meta_module(monkeypatch)
|
||||||
|
|
||||||
|
class BrokenSession:
|
||||||
|
async def execute(self, _query):
|
||||||
|
raise SQLAlchemyError("pg unavailable")
|
||||||
|
|
||||||
|
class DummyTimescaleConnection:
|
||||||
|
def cursor(self):
|
||||||
|
raise AssertionError("timescale should not be queried after postgres failure")
|
||||||
|
|
||||||
|
app = build_test_app(module.router, "/api/v1")
|
||||||
|
app.dependency_overrides[module.get_project_pg_session] = lambda: BrokenSession()
|
||||||
|
app.dependency_overrides[module.get_project_timescale_connection] = lambda: DummyTimescaleConnection()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/api/v1/meta/db/health")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json()["detail"] == "Project PostgreSQL health check failed: pg unavailable"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -7,10 +8,39 @@ from tests.conftest import build_test_app, install_stub, load_module_from_path
|
|||||||
|
|
||||||
def _load_simulation_module(monkeypatch):
|
def _load_simulation_module(monkeypatch):
|
||||||
install_stub(monkeypatch, "app.services", package=True)
|
install_stub(monkeypatch, "app.services", package=True)
|
||||||
|
def parse_aware_time(value, field_name="datetime"):
|
||||||
|
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
raise ValueError(f"{field_name} is missing timezone information.")
|
||||||
|
return dt
|
||||||
|
|
||||||
|
def parse_utc_time(value, field_name="datetime"):
|
||||||
|
return parse_aware_time(value, field_name=field_name).astimezone(
|
||||||
|
timezone.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.services.time_api",
|
||||||
|
{
|
||||||
|
"parse_aware_time": parse_aware_time,
|
||||||
|
"parse_utc_time": parse_utc_time,
|
||||||
|
},
|
||||||
|
)
|
||||||
install_stub(
|
install_stub(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
"app.services.simulation",
|
"app.services.simulation",
|
||||||
{"run_simulation": lambda **kwargs: None},
|
{
|
||||||
|
"run_simulation": lambda **kwargs: None,
|
||||||
|
"query_corresponding_element_id_and_query_id": lambda name: None,
|
||||||
|
"query_corresponding_pattern_id_and_query_id": lambda name: None,
|
||||||
|
"query_non_realtime_region": lambda name: [],
|
||||||
|
"get_source_outflow_region_id": lambda name, region_result: {},
|
||||||
|
"query_realtime_region_pipe_flow_and_demand_id": lambda name, region_result: {},
|
||||||
|
"query_pipe_flow_region_patterns": lambda name: {},
|
||||||
|
"query_non_realtime_region_patterns": lambda name, region_result: {},
|
||||||
|
"get_realtime_region_patterns": lambda name, source_outflow_region_id, realtime_region_pipe_flow_and_demand_id: ({}, {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
install_stub(monkeypatch, "app.services.globals", {})
|
install_stub(monkeypatch, "app.services.globals", {})
|
||||||
install_stub(
|
install_stub(
|
||||||
@@ -173,3 +203,158 @@ def test_network_update_surfaces_service_error(monkeypatch, tmp_path):
|
|||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert "数据库操作失败: write failed" in response.json()["detail"]
|
assert "数据库操作失败: write failed" in response.json()["detail"]
|
||||||
assert list(Path(tmp_path).glob("network_update_*"))
|
assert list(Path(tmp_path).glob("network_update_*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
captured_calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.simulation,
|
||||||
|
"run_simulation",
|
||||||
|
lambda **kwargs: captured_calls.append(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
module.run_simulation_manually_by_date(
|
||||||
|
"demo",
|
||||||
|
datetime(2025, 1, 1, 19, 4, 5, tzinfo=timezone.utc),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [call["modify_pattern_start_time"] for call in captured_calls] == [
|
||||||
|
"2025-01-01T19:04:05+00:00",
|
||||||
|
"2025-01-01T19:19:05+00:00",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run(network_name, start_time, duration):
|
||||||
|
captured["network_name"] = network_name
|
||||||
|
captured["start_time"] = start_time
|
||||||
|
captured["duration"] = duration
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "run_simulation_manually_by_date", fake_run)
|
||||||
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/runsimulationmanuallybydate/",
|
||||||
|
json={
|
||||||
|
"name": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"duration": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "success"}
|
||||||
|
assert captured["network_name"] == "demo"
|
||||||
|
assert captured["duration"] == 30
|
||||||
|
assert captured["start_time"].isoformat() == "2025-01-01T19:04:05+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runsimulationmanuallybydate_endpoint_rejects_naive_start_time(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/runsimulationmanuallybydate/",
|
||||||
|
json={
|
||||||
|
"name": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05",
|
||||||
|
"duration": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_valve_close_endpoint_passes_scheme_name(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_valve_close_analysis(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "valve_close_analysis", fake_valve_close_analysis)
|
||||||
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/valve_close_analysis/",
|
||||||
|
params={
|
||||||
|
"network": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"valves": ["V1", "V2"],
|
||||||
|
"duration": 900,
|
||||||
|
"scheme_name": "valve_case_01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "ok"
|
||||||
|
assert captured == {
|
||||||
|
"name": "demo",
|
||||||
|
"modify_pattern_start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"modify_total_duration": 900,
|
||||||
|
"modify_valve_opening": {"V1": 0.0, "V2": 0.0},
|
||||||
|
"scheme_name": "valve_case_01",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_flushing_endpoint_passes_required_scheme_name(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_flushing_analysis(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis)
|
||||||
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/flushing_analysis/",
|
||||||
|
params={
|
||||||
|
"network": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"valves": ["V1"],
|
||||||
|
"valves_k": [0.5],
|
||||||
|
"drainage_node_ID": "N1",
|
||||||
|
"flush_flow": 100.0,
|
||||||
|
"duration": 900,
|
||||||
|
"scheme_name": "flush_case_01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "ok"
|
||||||
|
assert captured == {
|
||||||
|
"name": "demo",
|
||||||
|
"modify_pattern_start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"modify_total_duration": 900,
|
||||||
|
"modify_valve_opening": {"V1": 0.5},
|
||||||
|
"drainage_node_ID": "N1",
|
||||||
|
"flushing_flow": 100.0,
|
||||||
|
"scheme_name": "flush_case_01",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_contaminant_endpoint_requires_scheme_name(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/contaminant_simulation/",
|
||||||
|
params={
|
||||||
|
"network": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"source": "N1",
|
||||||
|
"concentration": 10.0,
|
||||||
|
"duration": 900,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from tests.conftest import install_stub, load_module_from_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_scenarios_module(monkeypatch):
|
||||||
|
install_stub(monkeypatch, "app.services", package=True)
|
||||||
|
install_stub(monkeypatch, "app.algorithms", package=True)
|
||||||
|
install_stub(monkeypatch, "app.algorithms.simulation", package=True)
|
||||||
|
install_stub(monkeypatch, "app.services.simulation", {})
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.algorithms.simulation.runner",
|
||||||
|
{
|
||||||
|
"run_simulation_ex": lambda *args, **kwargs: json.dumps(
|
||||||
|
{"output": {"node_results": [], "link_results": []}}
|
||||||
|
),
|
||||||
|
"from_clock_to_seconds_2": lambda value: value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
install_stub(monkeypatch, "app.services.scheme_management", {"store_scheme_info": lambda *args, **kwargs: None})
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.services.tjnetwork",
|
||||||
|
{
|
||||||
|
"ChangeSet": type("ChangeSet", (), {}),
|
||||||
|
"OPTION_DEMAND_MODEL_PDA": "OPTION_DEMAND_MODEL_PDA",
|
||||||
|
"OPTION_QUALITY_CHEMICAL": "OPTION_QUALITY_CHEMICAL",
|
||||||
|
"SOURCE_TYPE_SETPOINT": "SOURCE_TYPE_SETPOINT",
|
||||||
|
"add_pattern": lambda *args, **kwargs: None,
|
||||||
|
"add_source": lambda *args, **kwargs: None,
|
||||||
|
"close_project": lambda *args, **kwargs: None,
|
||||||
|
"copy_project": lambda *args, **kwargs: None,
|
||||||
|
"delete_project": lambda *args, **kwargs: None,
|
||||||
|
"get_demand": lambda *args, **kwargs: None,
|
||||||
|
"get_emitter": lambda *args, **kwargs: None,
|
||||||
|
"get_node_links": lambda *args, **kwargs: None,
|
||||||
|
"get_option": lambda *args, **kwargs: None,
|
||||||
|
"get_pattern": lambda *args, **kwargs: None,
|
||||||
|
"get_pipe": lambda *args, **kwargs: None,
|
||||||
|
"get_source": lambda *args, **kwargs: None,
|
||||||
|
"get_time": lambda *args, **kwargs: None,
|
||||||
|
"have_project": lambda *args, **kwargs: False,
|
||||||
|
"is_junction": lambda *args, **kwargs: False,
|
||||||
|
"is_project_open": lambda *args, **kwargs: False,
|
||||||
|
"open_project": lambda *args, **kwargs: None,
|
||||||
|
"set_demand": lambda *args, **kwargs: None,
|
||||||
|
"set_emitter": lambda *args, **kwargs: None,
|
||||||
|
"set_option": lambda *args, **kwargs: None,
|
||||||
|
"set_source": lambda *args, **kwargs: None,
|
||||||
|
"set_time": lambda *args, **kwargs: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return load_module_from_path(
|
||||||
|
"tests_age_analysis_scenarios_module",
|
||||||
|
"app/algorithms/simulation/scenarios.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_analysis_passes_duration_by_keyword(monkeypatch):
|
||||||
|
module = _load_scenarios_module(monkeypatch)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "copy_project", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(module, "open_project", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(module, "close_project", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(module, "delete_project", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(module, "have_project", lambda *args, **kwargs: False)
|
||||||
|
monkeypatch.setattr(module, "is_project_open", lambda *args, **kwargs: False)
|
||||||
|
|
||||||
|
def fake_run_simulation_ex(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return json.dumps({"output": {"node_results": [], "link_results": []}})
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "run_simulation_ex", fake_run_simulation_ex)
|
||||||
|
|
||||||
|
module.age_analysis("demo", "2026-06-03T07:00:00+08:00", 300)
|
||||||
|
|
||||||
|
assert captured["args"] == (
|
||||||
|
"age_Anal_demo",
|
||||||
|
"realtime",
|
||||||
|
"2026-06-03T07:00:00+08:00",
|
||||||
|
)
|
||||||
|
assert captured["kwargs"] == {
|
||||||
|
"duration": 300,
|
||||||
|
"downloading_prohibition": True,
|
||||||
|
}
|
||||||
@@ -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