Разработка плагинов парсеров
Плагинная система позволяет добавлять поддержку новых языков и форматов тестовых файлов без изменения кода ядра SaveTest. Каждый язык или формат обрабатывается отдельным сервисом с единым HTTP-контрактом.
Для начала работы рекомендуется изучить готовые плагины из комплекта поставки в репозитории savelinkQA/savetest-plugins: parser-plugins/python-parser и parser-plugins/gherkin-parser — они служат рабочими примерами и содержат base_parser.py, который нужно взять за основу.
Назначение и устройство
Каждый плагин — отдельный микросервис на FastAPI с REST API для разбора файлов. Бэкенд SaveTest вызывает плагины по URL, объявленным в переменных окружения (PLUGIN_URLS), и при старте считывает метаданные через GET /config.
Поставляемые плагины (пример состава)
- python-parser
- Язык/формат: Python
.py - Фреймворки:
pytest,unittest
- Язык/формат: Python
- gherkin-parser
- Язык/формат: Gherkin
.feature - Фреймворки:
Cucumber,Behave
- Язык/формат: Gherkin
Состав набора может расширяться; структура каталога в комплекте разработчика плагинов: parser-plugins/<имя-плагина>/.
Режимы парсинга
POST /parse — один файл (legacy)
Один HTTP-запрос — один файл. Используется для обратной совместимости, если новый режим недоступен.
POST /parse_repo — рекомендуемый режим
Один запрос обрабатывает весь репозиторий или переданный список путей к файлам.
Преимущества:
- Меньше HTTP-запросов (условно один на язык вместо запроса на каждый файл)
- Возможность внутреннего кэша на объём репозитория (удобно при общих конфигах)
- Дедупликация
case_idв рамках одного языка на стороне плагина
Бэкенд автоматически выбирает /parse_repo, если endpoint указан в plugin.json. Если parse_repo нет, используется пофайловый режим.
Формат плагина
Ожидаемая структура каталога:
plugin-name/
├── plugin.json # Конфигурация плагина
├── Dockerfile # Docker-образ плагина
├── requirements.txt # Зависимости Python
├── base_parser.py # Базовые классы парсинга
├── src/
│ └── parser.py # FastAPI и логика парсера
└── README.md # Описание плагина (для команды)
Файл plugin.json
Обязательный файл с метаданными:
{
"name": "plugin-name",
"version": "1.1.0",
"display_name": "Display Name",
"description": "Description of what this parser does",
"language": "language-identifier",
"supported_extensions": [".ext"],
"file_patterns": ["**/*_test.ext"],
"api_version": "1.1",
"endpoints": {
"parse": "/parse",
"parse_repo": "/parse_repo",
"can_parse": "/can_parse",
"health": "/health",
"config": "/config"
},
"config": {
"port": 8000,
"timeout": 30,
"repo_timeout": 300
}
}
Поля в config:
| Поле | По умолчанию | Описание |
|---|---|---|
timeout | 30 | Таймаут (сек) для /parse — один файл |
repo_timeout | 300 | Таймаут (сек) для /parse_repo — весь репозиторий |
Обязательные endpoints API
POST /parse
Парсит один файл.
Тело запроса:
{
"file_path": "/path/to/test/file.py",
"repo_path": "/path/to/repository"
}
repo_path опционален; нужен для корректного suite_name и порядка файлов.
Ответ:
{
"success": true,
"metadata": [
{
"tms": "case-guid",
"file_path": "/path/to/file",
"suite_id": "suite-guid",
"suite_name": "Suite Name",
"title": "Test title",
"description": "Test description",
"severity": "normal",
"priority": "normal",
"tags": [],
"links": [],
"steps": [],
"iterations": [],
"custom_fields": []
}
],
"error": null
}
POST /parse_repo
Парсит репозиторий за один вызов.
Тело запроса:
{
"repo_path": "/path/to/repository",
"file_paths": ["/path/to/file1.py", "/path/to/file2.py"]
}
file_paths опционален: если не передан, плагин сам обходит репозиторий по паттернам из plugin.json.
Ответ:
{
"success": true,
"metadata": [
{
"tms": "case-guid",
"file_path": "/path/to/file",
"suite_id": "suite-guid",
"suite_name": "Suite Name",
"title": "Test title",
"steps": [],
"custom_fields": []
}
],
"errors": [],
"files_processed": 42,
"files_failed": 0
}
Обработка ошибок в /parse_repo:
- Ошибка в одном файле → строка в
errors, остальные файлы обрабатываются - Дубликат
case_idмежду файлами → вerrors, второй экземпляр не попадает вmetadata - Дубликат
case_idвнутри файла → файл считается ошибочным целиком success: false, еслиfiles_failed > 0или есть межфайловые дубликатыcase_id
POST /can_parse
Запрос:
{
"file_path": "/path/to/file.py"
}
Ответ:
{
"can_parse": true
}
GET /health
Ответ:
{
"status": "ok",
"plugin_name": "plugin-name",
"version": "1.1.0"
}
GET /config
Возвращает содержимое plugin.json. Бэкенд использует это для автоматической регистрации плагина при старте.
Проверки дубликатов
| Уровень | Где | Что |
|---|---|---|
| Внутри файла | Плагин (_validate_and_process_metadata) | Дубликаты case_id в одном файле |
| Между файлами, один язык | Плагин (check_cross_file_duplicates в /parse_repo) | Дубликаты case_id между файлами |
| Между языками | Экстрактор (_extract_via_parse_repo) | Дубликаты при слиянии результатов плагинов |
| Suite / каталоги | Экстрактор (_validate_extracted_metadata) | suite_id в разных директориях |
Пошаговая разработка нового плагина
1. Структура каталогов
mkdir -p parser-plugins/my-parser/src
cd parser-plugins/my-parser
2. Файл base_parser.py
Скопируйте из готового плагина — parser-plugins/python-parser/base_parser.py. В нём определены базовые классы BaseParser, TestMetadata и метод check_cross_file_duplicates, который используется в /parse_repo.
3. plugin.json
Скопируйте шаблон с другого плагина и адаптируйте. В endpoints обязательно укажите "parse_repo": "/parse_repo".
4. Реализация в src/parser.py
Класс парсера наследуется от BaseParser и реализует:
can_parse(file_path: Path) -> boolparse_file(file_path: Path) -> List[TestMetadata]
Подключите маршруты /parse и /parse_repo. Пример каркаса для /parse_repo:
from base_parser import BaseParser, TestMetadata, ParserError, ParserValidationError
class ParseRepoRequest(BaseModel):
repo_path: str
file_paths: Optional[List[str]] = None
class ParseRepoResponse(BaseModel):
success: bool
metadata: List[Dict[str, Any]]
errors: List[str] = []
files_processed: int = 0
files_failed: int = 0
@app.post("/parse_repo", response_model=ParseRepoResponse)
async def parse_repo(request: ParseRepoRequest):
repo_path = Path(request.repo_path)
if not repo_path.exists():
return ParseRepoResponse(success=False, metadata=[],
errors=[f"repo_path не найден: {repo_path}"])
if request.file_paths is not None:
file_paths = [Path(fp) for fp in request.file_paths]
else:
# Обход репозитория по вашим паттернам
file_paths = [...]
all_raw, errors = [], []
files_processed = files_failed = 0
for file_path in file_paths:
try:
all_raw.extend(parser.parse_file(file_path))
files_processed += 1
except (ParserError, ParserValidationError) as e:
errors.append(f"{file_path}: {e}")
files_failed += 1
clean, dup_errors = parser.check_cross_file_duplicates(all_raw)
errors.extend(dup_errors)
return ParseRepoResponse(
success=files_failed == 0 and not dup_errors,
metadata=[m.to_dict() for m in clean],
errors=errors,
files_processed=files_processed,
files_failed=files_failed,
)
5. Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY base_parser.py /app/
COPY plugin.json /app/
COPY src/ /app/src/
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
CMD ["uvicorn", "src.parser:app", "--host", "0.0.0.0", "--port", "8000"]
6. requirements.txt
Минимум:
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
7. Сервис в docker-compose.yml
my-parser-plugin:
build:
context: ./parser-plugins/my-parser
dockerfile: Dockerfile
container_name: my-parser-plugin
restart: unless-stopped
volumes:
- git_repos:/app/git_repos:ro
networks:
- savetest-network
environment:
- PLUGIN_NAME=my-parser
- LOG_LEVEL=INFO
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
Имена сети и тома (savetest-network, git_repos) должны совпадать с остальным стеком SaveTest.
8. Регистрация на бэкенде
Ручная правка кода бэкенда не нужна: при старте подтягивается GET /config у плагина. Добавьте URL вашего сервиса в PLUGIN_URLS (список через запятую) или в PLUGIN_1_URL, PLUGIN_2_URL, … у сервиса backend — формат и значения по умолчанию описаны в Параметры окружения.
После изменений пересоберите образ плагина и перезапустите стек:
docker compose up -d --build
Убедитесь, что плагин зарегистрировался: в интерфейсе SaveTest при создании проекта в режиме «Код автотестов» должен появиться новый парсер в списке доступных языков.
9. Документация плагина
В README.md плагина имеет смысл описать:
- Поддерживаемые фреймворки и версии
- Декораторы, аннотации, соглашения об именовании
- Примеры тестовых файлов
- Особенности парсинга и ограничения
Локальный запуск плагина вне Docker
Если процесс плагина слушает на хосте, а backend в контейнере, в PLUGIN_URLS укажите адрес, доступный из контейнера: например host.docker.internal (Docker Desktop) или IP хоста в вашей сети. Убедитесь, что firewall и bind-адрес сервиса плагина позволяют такое подключение.