- split_host_path(): разделяет host/path на (scheme, hostname, base_path), поддерживает варианты без схемы и с ней - REST_BASE собирается с учётом пути - WS: hostname и base_path разделяются, WS_PATH_TEMPLATE включает base_path - FPG_WS_PORT теперь настраивается через env (было захардкожено 8443) - fpg_connect.py: варианты URI переписаны на WS_HOSTNAME/WS_BASE_PATH
472 lines
20 KiB
Python
472 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Скрипт для подключения к SDK ФПГ (GigaDoc) и тестирования всех этапов.
|
||
|
||
Этапы:
|
||
1. Авторизация (получение access token)
|
||
2. Создание сессии
|
||
3. Подключение к WebSocket
|
||
4. Отправка изображений
|
||
5. Получение результатов
|
||
6. Удаление сессии
|
||
|
||
Логирование ведётся в файл fpg_test.log
|
||
"""
|
||
|
||
import argparse
|
||
import asyncio
|
||
import glob
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import requests
|
||
|
||
# ─── Конфигурация ─────────────────────────────────────────────
|
||
# ВАЖНО: хост может содержать не только домен, но и путь
|
||
# (например "domain.com/v1/giga") — это учитывается при сборке URL.
|
||
OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token")
|
||
CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr")
|
||
CLIENT_SECRET = os.environ.get("FPG_CLIENT_SECRET", "")
|
||
|
||
|
||
def split_host_path(host: str) -> tuple:
|
||
"""Разделить 'host/path' на (scheme, hostname, base_path)."""
|
||
host = host.strip().rstrip("/")
|
||
scheme = ""
|
||
if "://" in host:
|
||
scheme, host = host.split("://", 1)
|
||
parts = host.split("/", 1)
|
||
hostname = parts[0]
|
||
base_path = "/" + parts[1] if len(parts) > 1 else ""
|
||
return scheme, hostname, base_path
|
||
|
||
|
||
REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||
_rest_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST)
|
||
REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}"
|
||
|
||
# WebSocket (рабочий хост подтверждён: gigadoc-dnr-sdk.sbermed.ai:8443)
|
||
WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||
WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443"))
|
||
_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST)
|
||
WS_PATH_TEMPLATE = f"{WS_BASE_PATH}/api/v2/ws/predict/{{uid}}/image"
|
||
WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai")
|
||
|
||
DEVICE_ID = "web_prod"
|
||
FORM_FACTOR = "web_prod"
|
||
|
||
LOG_FILE = "fpg_test.log"
|
||
|
||
|
||
# ─── Логирование ──────────────────────────────────────────────
|
||
def setup_logging(log_file: str = LOG_FILE):
|
||
"""Настроить логирование в файл и консоль."""
|
||
logger = logging.getLogger("fpg")
|
||
logger.setLevel(logging.DEBUG)
|
||
|
||
# Формат
|
||
formatter = logging.Formatter(
|
||
"%(asctime)s [%(levelname)s] %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S",
|
||
)
|
||
|
||
# Файловый хендлер
|
||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||
fh.setLevel(logging.DEBUG)
|
||
fh.setFormatter(formatter)
|
||
logger.addHandler(fh)
|
||
|
||
# Консольный хендлер
|
||
ch = logging.StreamHandler(sys.stdout)
|
||
ch.setLevel(logging.INFO)
|
||
ch.setFormatter(formatter)
|
||
logger.addHandler(ch)
|
||
|
||
# Включаем логирование библиотек (requests, websockets, urllib3)
|
||
logging.getLogger("urllib3").setLevel(logging.DEBUG)
|
||
logging.getLogger("urllib3").addHandler(fh)
|
||
logging.getLogger("websockets").setLevel(logging.DEBUG)
|
||
logging.getLogger("websockets").addHandler(fh)
|
||
logging.getLogger("asyncio").setLevel(logging.DEBUG)
|
||
logging.getLogger("asyncio").addHandler(fh)
|
||
|
||
return logger
|
||
|
||
|
||
logger = logging.getLogger("fpg")
|
||
|
||
|
||
def log_headers(headers: dict, prefix: str = " "):
|
||
"""Залогировать HTTP-заголовки (маскируя секреты)."""
|
||
for k, v in headers.items():
|
||
if k.lower() in ("authorization", "client_secret", "cookie", "set-cookie"):
|
||
# Маскируем секретные заголовки
|
||
if isinstance(v, str) and len(v) > 20:
|
||
v = v[:20] + "..." + v[-5:]
|
||
else:
|
||
v = "***"
|
||
logger.debug(f"{prefix}{k}: {v}")
|
||
|
||
|
||
def log_response(resp, prefix: str = " "):
|
||
"""Залогировать HTTP-ответ подробно."""
|
||
logger.debug(f"{prefix}HTTP {resp.status_code} {resp.reason}")
|
||
logger.debug(f"{prefix}Headers:")
|
||
log_headers(dict(resp.headers), prefix + " ")
|
||
body = resp.text
|
||
if len(body) > 2000:
|
||
logger.debug(f"{prefix}Body ({len(body)} bytes): {body[:2000]}...")
|
||
else:
|
||
logger.debug(f"{prefix}Body: {body}")
|
||
|
||
|
||
# ─── Этап 1: Авторизация ──────────────────────────────────────
|
||
def get_token() -> str:
|
||
"""Получить access token через client_credentials."""
|
||
logger.info("=" * 60)
|
||
logger.info("ЭТАП 1: Авторизация")
|
||
logger.info("=" * 60)
|
||
logger.info(f"POST {OAUTH_URL}")
|
||
logger.debug(" Запрос:")
|
||
logger.debug(f" grant_type=client_credentials")
|
||
logger.debug(f" client_id={CLIENT_ID}")
|
||
logger.debug(f" client_secret={'*' * len(CLIENT_SECRET)}")
|
||
|
||
try:
|
||
resp = requests.post(
|
||
OAUTH_URL,
|
||
data={
|
||
"grant_type": "client_credentials",
|
||
"client_id": CLIENT_ID,
|
||
"client_secret": CLIENT_SECRET,
|
||
},
|
||
timeout=15,
|
||
)
|
||
logger.info(f"HTTP {resp.status_code}")
|
||
logger.debug(" Ответ:")
|
||
log_response(resp, " ")
|
||
|
||
resp.raise_for_status()
|
||
|
||
data = resp.json()
|
||
token = data["access_token"]
|
||
expires_in = data.get("expires_in", "?")
|
||
logger.info(f"✅ Токен получен (действует {expires_in} сек)")
|
||
logger.debug(f" token_type: {data.get('token_type')}")
|
||
logger.debug(f" scope: {data.get('scope')}")
|
||
logger.debug(f" refresh_expires_in: {data.get('refresh_expires_in')}")
|
||
logger.debug(f" not-before-policy: {data.get('not-before-policy')}")
|
||
logger.debug(f" access_token: {token[:80]}...")
|
||
logger.debug(f" access_token (полный, {len(token)} chars): {token}")
|
||
return token
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
logger.error(f"❌ Ошибка авторизации: {e}")
|
||
if hasattr(e, "response") and e.response is not None:
|
||
logger.error(f" Статус: {e.response.status_code}")
|
||
logger.error(f" Ответ: {e.response.text}")
|
||
raise
|
||
|
||
|
||
# ─── Этап 2: Создание сессии ──────────────────────────────────
|
||
def create_session(token: str) -> str:
|
||
"""Создать сессию и вернуть uid."""
|
||
logger.info("=" * 60)
|
||
logger.info("ЭТАП 2: Создание сессии")
|
||
logger.info("=" * 60)
|
||
url = f"{REST_BASE}/api/v1/predict/new"
|
||
logger.info(f"POST {url}")
|
||
logger.debug(" Запрос:")
|
||
logger.debug(f" device_id={DEVICE_ID}")
|
||
logger.debug(f" form_factor={FORM_FACTOR}")
|
||
logger.debug(f" Authorization: Bearer {token[:30]}...")
|
||
|
||
try:
|
||
resp = requests.post(
|
||
url,
|
||
params={"device_id": DEVICE_ID, "form_factor": FORM_FACTOR},
|
||
headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Accept": "application/json",
|
||
},
|
||
timeout=15,
|
||
)
|
||
logger.info(f"HTTP {resp.status_code}")
|
||
logger.debug(" Ответ:")
|
||
log_response(resp, " ")
|
||
|
||
resp.raise_for_status()
|
||
|
||
uid = resp.json()["uid"]
|
||
logger.info(f"✅ Сессия создана: {uid}")
|
||
return uid
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
logger.error(f"❌ Ошибка создания сессии: {e}")
|
||
if hasattr(e, "response") and e.response is not None:
|
||
logger.error(f" Статус: {e.response.status_code}")
|
||
logger.error(f" Ответ: {e.response.text}")
|
||
raise
|
||
|
||
|
||
# ─── Этап 3: WebSocket ────────────────────────────────────────
|
||
async def ws_scan(token: str, uid: str, ws_host: str, ws_port: int, image_dir: str):
|
||
"""Подключиться по WebSocket, отправить изображения, получить результат."""
|
||
import socket
|
||
import ssl
|
||
import websockets
|
||
|
||
logger.info("=" * 60)
|
||
logger.info("ЭТАП 3: WebSocket подключение")
|
||
logger.info("=" * 60)
|
||
|
||
# ── DNS резолв ──
|
||
logger.info(f"DNS резолв: {ws_host}")
|
||
try:
|
||
ips = socket.getaddrinfo(ws_host, ws_port)
|
||
for addr in ips:
|
||
logger.debug(f" {addr[0].name} -> {addr[4]}")
|
||
logger.info(f"✅ DNS резолв успешен: {len(ips)} записей")
|
||
except socket.gaierror as e:
|
||
logger.warning(f"⚠️ DNS резолв не удался: {e}")
|
||
logger.warning(f" Хост {ws_host} не найден. Пробуем другие варианты...")
|
||
|
||
# ── Собираем изображения ──
|
||
patterns = ["*.jpg", "*.jpeg", "*.png"]
|
||
images = []
|
||
for p in patterns:
|
||
images.extend(glob.glob(os.path.join(image_dir, p)))
|
||
images.sort()
|
||
|
||
if not images:
|
||
logger.warning(f"Нет изображений в {image_dir}, использую тестовое")
|
||
images = ["/tmp/test_face.jpg"]
|
||
if not os.path.exists(images[0]):
|
||
logger.error("Тестовое изображение не найдено")
|
||
return None
|
||
|
||
logger.info(f"Найдено изображений: {len(images)}")
|
||
for img in images:
|
||
size = os.path.getsize(img)
|
||
logger.debug(f" {img} ({size} bytes)")
|
||
|
||
# ── Пробуем несколько вариантов URI ──
|
||
# ws_host может быть с путём (domain.com/v1/giga) — разделяем.
|
||
_scheme, _h, _p = split_host_path(ws_host)
|
||
_ws_hostname = _h
|
||
_ws_path = _p or WS_BASE_PATH
|
||
uris = [
|
||
f"wss://{_ws_hostname}:{ws_port}{_ws_path}/api/v2/ws/predict/{uid}/image",
|
||
f"wss://{WS_HOSTNAME}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}",
|
||
f"wss://{WS_HOSTNAME}:443{WS_PATH_TEMPLATE.format(uid=uid)}",
|
||
]
|
||
|
||
last_error = None
|
||
for uri in uris:
|
||
logger.info(f"Попытка подключения: {uri}")
|
||
logger.info(" ── Заголовки WebSocket-запроса ──")
|
||
logger.info(f" GET {WS_PATH_TEMPLATE.format(uid=uid)} HTTP/1.1")
|
||
logger.info(f" Host: {ws_host}")
|
||
logger.info(f" Upgrade: websocket")
|
||
logger.info(f" Connection: Upgrade")
|
||
logger.info(f" Sec-WebSocket-Version: 13")
|
||
logger.info(f" Sec-WebSocket-Protocol: bearer.<TOKEN> (авторизация через subprotocol)")
|
||
logger.info(f" Origin: https://gigadoc.sbermed.ai")
|
||
logger.info(f" (полный токен: {token})")
|
||
logger.info(" ────────────────────────────────")
|
||
|
||
try:
|
||
async with websockets.connect(
|
||
uri,
|
||
open_timeout=10,
|
||
subprotocols=[f"bearer.{token}"],
|
||
origin=WS_ORIGIN,
|
||
extra_headers={
|
||
"Cache-Control": "no-cache",
|
||
"Pragma": "no-cache",
|
||
},
|
||
compression="deflate",
|
||
ping_interval=20,
|
||
ping_timeout=20,
|
||
max_size=None,
|
||
) as ws:
|
||
logger.info(f"✅ WebSocket подключен: {uri}")
|
||
logger.debug(f" Протокол: {ws.subprotocol}")
|
||
logger.debug(f" Расширения: {ws.extensions if hasattr(ws, 'extensions') else 'n/a'}")
|
||
|
||
# Отправляем изображения
|
||
logger.info("=" * 60)
|
||
logger.info("ЭТАП 4: Отправка изображений")
|
||
logger.info("=" * 60)
|
||
|
||
result = None
|
||
for i, img_path in enumerate(images):
|
||
with open(img_path, "rb") as f:
|
||
img_data = f.read()
|
||
|
||
logger.info(f" [{i+1}/{len(images)}] WS → отправка изображения: {img_path} ({len(img_data)} bytes, binary)")
|
||
t_start = time.time()
|
||
await ws.send(img_data)
|
||
t_send = time.time() - t_start
|
||
logger.debug(f" Отправлено за {t_send*1000:.1f} мс")
|
||
|
||
# Читаем ответ
|
||
try:
|
||
resp = await asyncio.wait_for(ws.recv(), timeout=5)
|
||
t_recv = time.time() - t_start
|
||
logger.debug(f" Ответ получен за {t_recv*1000:.1f} мс")
|
||
|
||
try:
|
||
data = json.loads(resp)
|
||
progress = data.get("progress", "?")
|
||
face_ok = data.get("face_ok", "?")
|
||
|
||
# Логируем прогресс
|
||
if i % 10 == 0 or (isinstance(progress, (int, float)) and progress >= 1.0):
|
||
logger.info(f" [{i+1}/{len(images)}] progress={progress} face_ok={face_ok}")
|
||
|
||
# Логируем полный ответ на DEBUG
|
||
logger.debug(f" Полный ответ: {json.dumps(data, ensure_ascii=False)}")
|
||
|
||
# Финальный результат
|
||
if isinstance(progress, (int, float)) and progress >= 1.0:
|
||
logger.info("=" * 60)
|
||
logger.info("ЭТАП 5: ФИНАЛЬНЫЙ РЕЗУЛЬТАТ")
|
||
logger.info("=" * 60)
|
||
logger.info(json.dumps(data, indent=2, ensure_ascii=False))
|
||
result = data
|
||
break
|
||
except json.JSONDecodeError:
|
||
logger.debug(f" Ответ не JSON: {resp[:200]}")
|
||
except asyncio.TimeoutError:
|
||
logger.debug(f" [{i+1}/{len(images)}] Таймаут ожидания ответа (5 сек)")
|
||
|
||
if result is None:
|
||
logger.warning("Финальный результат не получен (прогресс не достиг 1.0)")
|
||
return result
|
||
|
||
except Exception as e:
|
||
last_error = e
|
||
logger.warning(f"❌ Не удалось подключиться к {uri}: {type(e).__name__}: {e}")
|
||
logger.debug(f" Детали ошибки: {e}")
|
||
|
||
logger.error(f"❌ Все варианты WebSocket подключения не удались")
|
||
logger.error(f" Последняя ошибка: {type(last_error).__name__}: {last_error}")
|
||
return None
|
||
|
||
|
||
# ─── Этап 6: Удаление сессии ──────────────────────────────────
|
||
def delete_session(token: str, uid: str):
|
||
"""Удалить сессию."""
|
||
logger.info("=" * 60)
|
||
logger.info("ЭТАП 6: Удаление сессии")
|
||
logger.info("=" * 60)
|
||
url = f"{REST_BASE}/api/v1/predict/{uid}"
|
||
logger.info(f"DELETE {url}")
|
||
logger.debug(f" Authorization: Bearer {token[:30]}...")
|
||
|
||
try:
|
||
resp = requests.delete(
|
||
url,
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
timeout=15,
|
||
)
|
||
logger.info(f"HTTP {resp.status_code}")
|
||
logger.debug(" Ответ:")
|
||
log_response(resp, " ")
|
||
|
||
if resp.status_code == 200:
|
||
logger.info(f"✅ Сессия {uid} удалена")
|
||
else:
|
||
logger.warning(f"⚠️ Сессия {uid} не удалена: {resp.status_code} {resp.text}")
|
||
except requests.exceptions.RequestException as e:
|
||
logger.error(f"❌ Ошибка удаления сессии: {e}")
|
||
if hasattr(e, "response") and e.response is not None:
|
||
logger.error(f" Статус: {e.response.status_code}")
|
||
logger.error(f" Ответ: {e.response.text}")
|
||
|
||
|
||
# ─── Создание тестового изображения ──────────────────────────
|
||
def create_test_image():
|
||
"""Создать тестовое JPEG-изображение если нет своих."""
|
||
try:
|
||
from PIL import Image
|
||
|
||
img = Image.new("RGB", (1280, 720), color=(128, 128, 128))
|
||
img.save("/tmp/test_face.jpg", "JPEG", quality=80)
|
||
logger.info("Создано тестовое изображение /tmp/test_face.jpg")
|
||
except ImportError:
|
||
logger.warning("PIL не установлен, тестовое изображение не создано")
|
||
|
||
|
||
# ─── Main ─────────────────────────────────────────────────────
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Тест SDK ФПГ (GigaDoc)")
|
||
parser.add_argument("--ws-host", default=WS_HOST, help=f"WebSocket host (default: {WS_HOST})")
|
||
parser.add_argument("--ws-port", type=int, default=WS_PORT, help=f"WebSocket port (default: {WS_PORT})")
|
||
parser.add_argument("--image-dir", default=".", help="Директория с JPEG-файлами (default: .)")
|
||
parser.add_argument("--log-file", default=LOG_FILE, help=f"Файл лога (default: {LOG_FILE})")
|
||
parser.add_argument("--rest-only", action="store_true", help="Только REST API (без WebSocket)")
|
||
args = parser.parse_args()
|
||
|
||
# Настройка логирования
|
||
setup_logging(args.log_file)
|
||
logger.info("=" * 60)
|
||
logger.info("🏥 Тестирование SDK ФПГ (GigaDoc)")
|
||
logger.info(f"Дата: {datetime.now().isoformat()}")
|
||
logger.info(f"REST: {REST_BASE}")
|
||
logger.info(f"WS: {args.ws_host}:{args.ws_port}")
|
||
logger.info("=" * 60)
|
||
|
||
# 0. Тестовое изображение
|
||
create_test_image()
|
||
|
||
# 1. Авторизация
|
||
try:
|
||
token = get_token()
|
||
except Exception:
|
||
logger.error("Тест прерван: не удалось авторизоваться")
|
||
return 1
|
||
|
||
# 2. Создание сессии
|
||
try:
|
||
uid = create_session(token)
|
||
except Exception:
|
||
logger.error("Тест прерван: не удалось создать сессию")
|
||
return 1
|
||
|
||
# 3-5. WebSocket
|
||
result = None
|
||
if not args.rest_only:
|
||
try:
|
||
result = asyncio.run(
|
||
ws_scan(token, uid, args.ws_host, args.ws_port, args.image_dir)
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка WebSocket: {type(e).__name__}: {e}")
|
||
else:
|
||
logger.info("WebSocket тест пропущен (--rest-only)")
|
||
|
||
# 6. Удаление сессии
|
||
delete_session(token, uid)
|
||
|
||
# Итог
|
||
logger.info("=" * 60)
|
||
if result:
|
||
logger.info("✅ Тест завершён успешно — результат получен")
|
||
else:
|
||
logger.info("⚠️ REST API работает, WebSocket недоступен")
|
||
logger.info(f"Лог сохранён в: {args.log_file}")
|
||
logger.info("=" * 60)
|
||
|
||
return 0 if result else 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|