v0.5.0: 3 стенда — изоляция состояния, WSGI middleware, OpenAPI server selector, POLYGON_STANDS env

- loader.py: STANDS dict, get_services/get_ops_index per stand, POLYGON_STANDS env
- mock_state.py: get_state(stand_id) — ленивая инициализация
- app.py: StandMiddleware — перезапись PATH_INFO до Flask-роутинга
- Все роуты: LocalProxy(state), LocalProxy(SERVICES/OPS_INDEX) через g.stand_id
- openapi.py: _build_servers() — по одному серверу на стенд
- services/stand{1,2,3}/ — 37 YAML на стенд из STANDS/dev и STANDS/test
- tests/test_api.py: +4 multi-stand теста (изоляция, fail-next, OpenAPI servers)
- Обратная совместимость: /api/v1/svc/... → стенд по умолчанию
This commit is contained in:
2026-08-01 20:10:19 +04:00
parent 11f98738d0
commit 58d54f2a95
122 changed files with 25223 additions and 76 deletions
+59 -35
View File
@@ -1,74 +1,98 @@
"""
config/loader.py — загрузка сервисов и глобальные конфигурационные переменные.
Этот модуль загружает все YAML-конфиги сервисов при первом импорте и хранит их
как модульные переменные. Все routes/*.py импортируют SERVICES, OPS_INDEX, DELAY,
VERSION отсюда — без дублирования загрузки и без циклических импортов.
Поддержка нескольких стендов:
Переменная POLYGON_STANDS задаёт список стендов в формате:
имя:путь,имя:путь,...
Например:
POLYGON_STANDS=stand1:services/stand1,stand2:services/stand2,stand3:services/stand3
Переменные модуля (вычисляются ОДИН раз при старте):
VERSION — str, версия полигона (единая для app.py и root.py)
SERVICES — {service_id: service_def} все сервисы
OPS_INDEX — {svcOperationId: service_def} поиск сервиса по ID операции
DELAY — float, задержка операции из MOCK_OP_DELAY (по умолчанию 0.1)
По умолчанию — один стенд "default" из папки services/.
Переменные модуля:
VERSION — str, версия полигона
STANDS — {stand_id: {"SERVICES": ..., "OPS_INDEX": ...}}
DELAY — float, задержка операции
DEFAULT_STAND — str, ID стенда по умолчанию
"""
import os
import yaml
# Константы для загрузки YAML
_YAML_EXTS = (".yaml", ".yml")
_DEFAULT_SERVICES_DIR = "services"
def _load_all(services_dir=_DEFAULT_SERVICES_DIR):
"""Загружает все YAML-файлы из директории сервисов.
Возвращает кортеж (services, ops_index):
services — {service_id: {name, operations, cfsParams, stateParams, stateOut, ...}}
ops_index — {svcOperationId: service_def} для быстрого поиска «чей это opId»
"""
def _load_one(services_dir):
"""Загружает YAML из одной директории → (services, ops_index)."""
if not os.path.isdir(services_dir):
raise FileNotFoundError(f"Services directory not found: {services_dir}")
services = {}
ops_index = {}
# Итерируем ВСЕ .yaml/.yml — без хардкода количества сервисов
for fname in sorted(os.listdir(services_dir)):
if not fname.lower().endswith(_YAML_EXTS):
continue
path = os.path.join(services_dir, fname)
with open(path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
if not cfg:
continue
svc_id = cfg["service_id"]
services[svc_id] = cfg
# Индекс: svcOperationId → service_def (нужен для поиска «чей это opId»)
for op in cfg.get("operations", []):
ops_index[op["svcOperationId"]] = cfg
return services, ops_index
# ---------------------------------------------------------------------------
# Модульные переменные — инициализируются ОДИН раз при первом импорте.
# Все routes/*.py импортируют SERVICES, OPS_INDEX, DELAY отсюда.
# ---------------------------------------------------------------------------
def _parse_stands(raw):
"""Парсит POLYGON_STANDS → {stand_id: services_dir}."""
stands = {}
for pair in raw.split(","):
pair = pair.strip()
if ":" not in pair:
continue
name, path = pair.split(":", 1)
stands[name.strip()] = path.strip()
return stands
# Загружаем конфиги сервисов (37+ YAML из services/)
SERVICES, OPS_INDEX = _load_all()
# Задержка операции в секундах (для синхронного sleep в /run).
# Меняется на лету через POST /_mock/delay/<s> — см. mock_routes.py.
# ⛔ Импортировать ТОЛЬКО через `import config.loader as _cfg` и читать `_cfg.DELAY` —
# иначе float копируется по значению и не реагирует на изменение.
# ── Загрузка ──
_raw = os.getenv("POLYGON_STANDS", "")
if _raw:
_stand_dirs = _parse_stands(_raw)
else:
_stand_dirs = {"default": _DEFAULT_SERVICES_DIR}
STANDS = {}
for _sid, _sdir in _stand_dirs.items():
_svc, _ops = _load_one(_sdir)
STANDS[_sid] = {"SERVICES": _svc, "OPS_INDEX": _ops}
DEFAULT_STAND = next(iter(STANDS.keys()))
# Для обратной совместимости (код который не использует g.stand_id)
_DF = STANDS[DEFAULT_STAND]
SERVICES = _DF["SERVICES"]
OPS_INDEX = _DF["OPS_INDEX"]
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
VERSION = "0.5.0"
# Версия полигона — единый источник правды для app.py и routes/root.py.
# Меняется при КАЖДОМ изменении кода.
VERSION = "0.4.3"
def get_services(stand_id):
"""SERVICES для конкретного стенда."""
return STANDS[stand_id]["SERVICES"]
def get_ops_index(stand_id):
"""OPS_INDEX для конкретного стенда."""
return STANDS[stand_id]["OPS_INDEX"]
def list_stands():
"""Список ID всех стендов."""
return list(STANDS.keys())