From 1b8394d0c0f7a89c9c753ac0e1abb6d7a385aef0 Mon Sep 17 00:00:00 2001 From: k0te1ch Date: Fri, 5 Jun 2026 16:53:02 +0300 Subject: [PATCH 1/6] feat(bot): drive episode upload dialog via dialog-engine Replace the aiogram UploadFile StatesGroup with the dialog-engine library. The three-step flow (episode type, MP3, metadata template) is now described as an engine schema; step order, validation and answer storage live in the library, while the handlers keep doing the Telegram-side work. Progress is stored as a serialised DialogSession in FSMContext, so a running upload still survives restarts. Routing moves from StateFilter to OnStep / InDialog filters backed by the session. dialog-engine is pulled from GitHub until it ships on PyPI. --- app/bot/filters/dialog_filters.py | 43 +++++++++++++ app/bot/forms/upload_file.py | 49 +++++++++++++-- app/bot/handlers/podcast_handler.py | 35 +++++++---- app/bot/poetry.lock | 23 ++++++- app/bot/pyproject.toml | 3 + app/bot/utils/dialog.py | 37 +++++++++++ tests/unit/filters/test_dialog_filters.py | 57 +++++++++++++++++ tests/unit/forms/test_upload_file.py | 61 +++++++++++++++++++ .../unit/handlers/podcast_handler/conftest.py | 47 ++++++++++++++ .../handlers/podcast_handler/test_cancel.py | 25 ++++---- .../handlers/podcast_handler/test_get_MP3.py | 21 ++++--- .../handlers/podcast_handler/test_get_type.py | 47 +++++++------- .../podcast_handler/test_set_template.py | 27 +++++--- .../handlers/podcast_handler/test_start.py | 26 ++++---- tests/unit/utils/dialog/test_dialog.py | 55 +++++++++++++++++ 15 files changed, 465 insertions(+), 91 deletions(-) create mode 100644 app/bot/filters/dialog_filters.py create mode 100644 app/bot/utils/dialog.py create mode 100644 tests/unit/filters/test_dialog_filters.py create mode 100644 tests/unit/forms/test_upload_file.py create mode 100644 tests/unit/handlers/podcast_handler/conftest.py create mode 100644 tests/unit/utils/dialog/test_dialog.py diff --git a/app/bot/filters/dialog_filters.py b/app/bot/filters/dialog_filters.py new file mode 100644 index 0000000..600a231 --- /dev/null +++ b/app/bot/filters/dialog_filters.py @@ -0,0 +1,43 @@ +"""aiogram filters that route messages by the active dialog step. + +These replace the aiogram ``StateFilter`` / ``StatesGroup`` membership checks. +Routing decisions are delegated to :mod:`dialog_engine`: a message reaches a +handler only when the user's :class:`~dialog_engine.DialogSession` is active and +sitting on the expected step. +""" + +from __future__ import annotations + +from aiogram.filters import BaseFilter +from aiogram.fsm.context import FSMContext +from aiogram.types import TelegramObject +from dialog_engine import DialogEngine + +from forms.upload_file import upload_file_engine +from utils.dialog import load_session + + +class InDialog(BaseFilter): + """Pass when the user has an active dialog session (on any step).""" + + def __init__(self, engine: DialogEngine = upload_file_engine) -> None: + self.engine = engine + + async def __call__(self, _event: TelegramObject, state: FSMContext) -> bool: + session = await load_session(state, self.engine) + return session is not None and session.is_active + + +class OnStep(BaseFilter): + """Pass when the active dialog session is currently on ``step_id``.""" + + def __init__(self, step_id: str, engine: DialogEngine = upload_file_engine) -> None: + self.step_id = step_id + self.engine = engine + + async def __call__(self, _event: TelegramObject, state: FSMContext) -> bool: + session = await load_session(state, self.engine) + if session is None or not session.is_active: + return False + current = self.engine.current_step(session) + return current is not None and current.id == self.step_id diff --git a/app/bot/forms/upload_file.py b/app/bot/forms/upload_file.py index d639a31..830bb56 100644 --- a/app/bot/forms/upload_file.py +++ b/app/bot/forms/upload_file.py @@ -1,7 +1,46 @@ -from aiogram.fsm.state import State, StatesGroup +"""Upload-episode dialog schema, driven by :mod:`dialog_engine`. +This replaces the former aiogram ``UploadFile`` ``StatesGroup``. The flow has +three steps — pick the episode type, send the MP3, send the metadata template — +and the engine owns step order, validation and answer storage. -class UploadFile(StatesGroup): - type_episode = State() - mp3 = State() - template = State() +The aiogram handlers in :mod:`handlers.podcast_handler` still render the +per-language prompts and perform the Telegram-side side effects (download, +tagging, upload); they consult this engine only for navigation and to read the +collected answers. +""" + +from dialog_engine import DialogEngine + +# Step IDs are referenced by the handlers and the ``OnStep`` filter, so keep +# them as named constants to avoid stringly-typed drift. +TYPE_EPISODE = "type_episode" +MP3 = "mp3" +TEMPLATE = "template" + +DIALOG_ID = "upload_file" + +# Canonical episode kinds stored in ``session.answers[TYPE_EPISODE]``. The +# display text is rendered per-language by the handlers/keyboards, so the +# ``choices`` values here are just i18n attribute names for reference. +upload_file_engine = DialogEngine.from_list( + [ + { + "id": TYPE_EPISODE, + "type": "choice", + "text": "ask_typeEpisode", + "choices": {"main": "main_episode", "aftershow": "episode_aftershow"}, + }, + { + "id": MP3, + "type": "file", + "text": "ask_mp3", + }, + { + "id": TEMPLATE, + "type": "text", + "text": "ask_template", + }, + ], + dialog_id=DIALOG_ID, +) diff --git a/app/bot/handlers/podcast_handler.py b/app/bot/handlers/podcast_handler.py index 40aab3a..30a03ab 100644 --- a/app/bot/handlers/podcast_handler.py +++ b/app/bot/handlers/podcast_handler.py @@ -5,7 +5,7 @@ from pathlib import Path from aiogram import Bot, F, Router -from aiogram.filters import CommandStart, StateFilter +from aiogram.filters import CommandStart from aiogram.fsm.context import FSMContext from aiogram.types import FSInputFile, Message, ReplyKeyboardRemove from loguru import logger @@ -21,9 +21,11 @@ LOCAL, PODCAST_PATH, ) +from filters.dialog_filters import InDialog, OnStep from filters.dispatcher_filters import ContextButton, IsAdmin, IsPrivate -from forms.upload_file import UploadFile +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine from services import context, keyboards +from utils.dialog import load_session, save_session, start_dialog from utils.FTP_methods import get_last_post_ID from utils.MP3_methods import audio_tag from utils.podcast_methods import generate_file_name @@ -66,11 +68,11 @@ async def start(msg: Message, state: FSMContext, language: str): context[language].ask_typeEpisode, reply_markup=keyboards["podcast_handler"][language].type_episode, ) - await state.set_state(UploadFile.type_episode) + await start_dialog(state, upload_file_engine) @logger.catch -@router.message(F.text, ContextButton("cancel"), StateFilter(UploadFile)) +@router.message(F.text, ContextButton("cancel"), InDialog()) async def cancel(msg: Message, state: FSMContext, language: str, username: str): """Отмена загрузки MP3.""" logger.debug(f"[{username}]: Отмена загрузки MP3") @@ -85,12 +87,14 @@ async def cancel(msg: Message, state: FSMContext, language: str, username: str): @router.message( F.text, ContextButton(["main_episode", "episode_aftershow"]), - UploadFile.type_episode, + OnStep(TYPE_EPISODE), ) async def get_type(msg: Message, state: FSMContext, language: str, username: str): """Выбор типа эпизода.""" type_episode = "main" if msg.text == context[language].main_episode else "aftershow" - await state.update_data(type_episode=type_episode) + session = await load_session(state, upload_file_engine) + await upload_file_engine.async_submit(session, type_episode) + await save_session(state, session) logger.debug(f"[{username}]: Выбран тип эпизода: {type_episode}") type_episode_text = ( # noqa: F841 — used by context format_map @@ -100,13 +104,13 @@ async def get_type(msg: Message, state: FSMContext, language: str, username: str context[language].ask_mp3, reply_markup=keyboards["podcast_handler"][language].cancel, ) - await state.set_state(UploadFile.mp3) @logger.catch -@router.message(UploadFile.mp3, F.audio) +@router.message(OnStep(MP3), F.audio) async def get_MP3(msg: Message, state: FSMContext, bot: Bot, language: str, username: str): """Обработка загрузки MP3.""" + session = await load_session(state, upload_file_engine) await clear_old_mp3_files() logger.debug(f"[{username}]: Загружает MP3...") @@ -153,24 +157,26 @@ async def progress_callback(bytes_uploaded: int): await state.clear() return - episode_data = await state.get_data() - type_episode = episode_data["type_episode"] + type_episode = session.answers[TYPE_EPISODE] numberLastEpisode = str(int(await get_last_post_ID(type_episode, FTP_SERVER, FTP_LOGIN, FTP_PASSWORD)) + 1) + await upload_file_engine.async_submit(session, msg.audio.file_id) + await save_session(state, session) + await download_msg.edit_text(context[language].downloaded) await msg.answer( context[language].ask_template[type_episode].replace("600", numberLastEpisode), reply_markup=keyboards["podcast_handler"][language].cancel, ) - await state.set_state(UploadFile.template) @logger.catch -@router.message(F.text, UploadFile.template, flags={"long_operation": "upload_audio"}) +@router.message(F.text, OnStep(TEMPLATE), flags={"long_operation": "upload_audio"}) async def set_template(msg: Message, state: FSMContext, language: str, username: str): """Обработка шаблона для MP3-тегов.""" - type_episode = (await state.get_data())["type_episode"] + session = await load_session(state, upload_file_engine) + type_episode = session.answers[TYPE_EPISODE] logger.debug(f"[{username}]: Выбранный тип эпизода: {type_episode}") info = validate_template(msg.text) @@ -220,4 +226,7 @@ async def progress_callback(bytes_uploaded: int): await tmp.delete() logger.debug(f"[{username}]: MP3 загружен и отправлен в чат") + + # Final step — mark the dialog complete, then drop the session from storage. + await upload_file_engine.async_submit(session, msg.text) await state.clear() diff --git a/app/bot/poetry.lock b/app/bot/poetry.lock index 46d84c2..2acb541 100644 --- a/app/bot/poetry.lock +++ b/app/bot/poetry.lock @@ -1120,6 +1120,27 @@ files = [ [package.dependencies] packaging = "*" +[[package]] +name = "dialog-engine" +version = "0.1.0" +description = "Universal multi-step dialog engine for building form-driven flows and Telegram bots." +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.extras] +aiogram = ["aiogram (>=3.0,<4.0)"] +validation = ["pydantic (>=2.0,<3.0)"] +yaml = ["PyYAML (>=6.0)"] + +[package.source] +type = "git" +url = "https://github.com/k0te1ch/DialogEngine.git" +reference = "HEAD" +resolved_reference = "be5bf7c9fbd252156a220dc3bfd0e6e8e4b9c521" + [[package]] name = "distlib" version = "0.4.0" @@ -3846,4 +3867,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.15" -content-hash = "705c8f8b44f4283dd69f826443c5178f1b8cea7e3c42ddf0ef4234542804ff96" +content-hash = "7be49509b6490f44accfd9e1ad908ad2de8df455dd46dd128227741c15e2e2fd" diff --git a/app/bot/pyproject.toml b/app/bot/pyproject.toml index eda42a3..f3f273a 100644 --- a/app/bot/pyproject.toml +++ b/app/bot/pyproject.toml @@ -33,6 +33,9 @@ pydantic = "^2.12.3" pydantic-settings = "^2.9.1" pre-commit = "^4.5.1" pytz = "^2026.1.post1" +# Multi-step dialog engine. Pulled from GitHub until it lands on PyPI, +# then this switches to a plain version constraint. +dialog-engine = { git = "https://github.com/k0te1ch/DialogEngine.git" } [tool.poetry.group.testing.dependencies] diff --git a/app/bot/utils/dialog.py b/app/bot/utils/dialog.py new file mode 100644 index 0000000..c9895e2 --- /dev/null +++ b/app/bot/utils/dialog.py @@ -0,0 +1,37 @@ +"""Glue between :mod:`dialog_engine` and aiogram's FSM storage. + +``dialog_engine`` is framework-agnostic: the schema lives in a stateless +:class:`~dialog_engine.DialogEngine` and all run-time state lives in a +serialisable :class:`~dialog_engine.DialogSession`. These helpers persist that +session inside aiogram's :class:`~aiogram.fsm.context.FSMContext` (backed by +Redis in production), so a running dialog survives restarts just like the old +FSM states did. +""" + +from __future__ import annotations + +from aiogram.fsm.context import FSMContext +from dialog_engine import DialogEngine, DialogSession + +# Key under which the serialised session lives in the FSM data dict. +SESSION_KEY = "dialog_session" + + +async def load_session(state: FSMContext, engine: DialogEngine) -> DialogSession | None: + """Return the dialog session stored in *state*, or ``None`` if absent.""" + raw = (await state.get_data()).get(SESSION_KEY) + if not raw: + return None + return engine.restore_session(raw) + + +async def save_session(state: FSMContext, session: DialogSession) -> None: + """Serialise *session* back into the FSM data.""" + await state.update_data({SESSION_KEY: session.to_dict()}) + + +async def start_dialog(state: FSMContext, engine: DialogEngine) -> DialogSession: + """Begin a fresh dialog, replacing any session already in *state*.""" + session = engine.create_session() + await save_session(state, session) + return session diff --git a/tests/unit/filters/test_dialog_filters.py b/tests/unit/filters/test_dialog_filters.py new file mode 100644 index 0000000..e72fcfd --- /dev/null +++ b/tests/unit/filters/test_dialog_filters.py @@ -0,0 +1,57 @@ +"""Tests for the dialog-step routing filters (``filters.dialog_filters``).""" + +import pytest +from aiogram.fsm.context import FSMContext +from aiogram.fsm.storage.base import StorageKey +from aiogram.fsm.storage.memory import MemoryStorage + +from filters.dialog_filters import InDialog, OnStep +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine +from utils.dialog import save_session, start_dialog + + +@pytest.fixture +def state() -> FSMContext: + storage = MemoryStorage() + key = StorageKey(bot_id=1, chat_id=1, user_id=1) + return FSMContext(storage=storage, key=key) + + +@pytest.mark.asyncio +async def test_in_dialog_false_without_session(state): + assert await InDialog()(None, state) is False + + +@pytest.mark.asyncio +async def test_in_dialog_true_with_active_session(state): + await start_dialog(state, upload_file_engine) + assert await InDialog()(None, state) is True + + +@pytest.mark.asyncio +async def test_on_step_matches_current_step_only(state): + await start_dialog(state, upload_file_engine) # on type_episode + assert await OnStep(TYPE_EPISODE)(None, state) is True + assert await OnStep(MP3)(None, state) is False + + +@pytest.mark.asyncio +async def test_on_step_follows_session_progress(state): + session = await start_dialog(state, upload_file_engine) + upload_file_engine.submit(session, "main") # advance to mp3 + await save_session(state, session) + + assert await OnStep(TYPE_EPISODE)(None, state) is False + assert await OnStep(MP3)(None, state) is True + + +@pytest.mark.asyncio +async def test_filters_false_after_dialog_completes(state): + session = await start_dialog(state, upload_file_engine) + upload_file_engine.submit(session, "main") + upload_file_engine.submit(session, "file_id") + upload_file_engine.submit(session, "Number: 1\nTitle: x\nComment: y") # completes + await save_session(state, session) + + assert await InDialog()(None, state) is False + assert await OnStep(TEMPLATE)(None, state) is False diff --git a/tests/unit/forms/test_upload_file.py b/tests/unit/forms/test_upload_file.py new file mode 100644 index 0000000..5839fb3 --- /dev/null +++ b/tests/unit/forms/test_upload_file.py @@ -0,0 +1,61 @@ +"""Tests for the upload-episode dialog schema (``forms.upload_file``). + +These exercise the ``dialog_engine`` schema directly — step order, the choice +constraint, media collection, completion and round-trip serialisation — so the +contract the handlers rely on is pinned independently of aiogram. +""" + +import pytest +from dialog_engine import DialogSession, SessionStatus, ValidationError + +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine + + +def test_schema_step_order_and_types(): + steps = upload_file_engine.steps + assert [s.id for s in steps] == [TYPE_EPISODE, MP3, TEMPLATE] + by_id = {s.id: s for s in steps} + assert by_id[TYPE_EPISODE].type == "choice" + assert by_id[TYPE_EPISODE].choices == {"main": "main_episode", "aftershow": "episode_aftershow"} + assert by_id[MP3].type == "file" + assert by_id[TEMPLATE].type == "text" + + +def test_first_step_is_type_episode(): + session = upload_file_engine.create_session() + assert upload_file_engine.current_step(session).id == TYPE_EPISODE + assert session.is_active + + +@pytest.mark.parametrize("type_episode", ["main", "aftershow"]) +def test_full_happy_path(type_episode): + session = upload_file_engine.create_session() + + nxt = upload_file_engine.submit(session, type_episode) + assert nxt.id == MP3 + assert session.answers[TYPE_EPISODE] == type_episode + + nxt = upload_file_engine.submit(session, "audio_file_id") + assert nxt.id == TEMPLATE + assert session.answers[MP3] == ["audio_file_id"] # file step normalises to a list + + nxt = upload_file_engine.submit(session, "Number: 1\nTitle: x\nComment: y") + assert nxt is None # dialog complete + assert session.status == SessionStatus.COMPLETED + + +def test_invalid_episode_type_is_rejected(): + session = upload_file_engine.create_session() + with pytest.raises(ValidationError): + upload_file_engine.submit(session, "not-a-choice") + # Session stays on the first step so the user can retry. + assert upload_file_engine.current_step(session).id == TYPE_EPISODE + + +def test_session_survives_serialisation_round_trip(): + session = upload_file_engine.create_session() + upload_file_engine.submit(session, "main") + + restored = DialogSession.from_dict(session.to_dict()) + assert restored.answers == session.answers + assert upload_file_engine.current_step(restored).id == MP3 diff --git a/tests/unit/handlers/podcast_handler/conftest.py b/tests/unit/handlers/podcast_handler/conftest.py new file mode 100644 index 0000000..d8f6438 --- /dev/null +++ b/tests/unit/handlers/podcast_handler/conftest.py @@ -0,0 +1,47 @@ +"""Shared fixtures for the podcast_handler dialog tests. + +The handlers were migrated from an aiogram ``StatesGroup`` to the +``dialog_engine`` library: the per-user progress now lives in a serialised +:class:`~dialog_engine.DialogSession` stored in the FSM data under +``utils.dialog.SESSION_KEY`` instead of in aiogram's ``state``. + +``aiogram_tests`` only injects ``state_data`` into storage when a non-empty +``state`` is also supplied (see ``TelegramEventObserverHandler.__call__``), so +tests pass the :func:`dialog_state` sentinel purely to trigger that injection — +the handlers themselves no longer read the aiogram state. +""" + +from collections.abc import Callable + +import pytest + +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine +from utils.dialog import SESSION_KEY + +# Sentinel aiogram state used only so aiogram_tests writes our state_data. +_DIALOG_STATE = "uploading" + + +@pytest.fixture +def dialog_state() -> str: + """Sentinel FSM state that makes aiogram_tests persist ``state_data``.""" + return _DIALOG_STATE + + +@pytest.fixture +def session_state_data() -> Callable[..., dict]: + """Factory building FSM ``state_data`` with a session on a given step. + + Steps are reached by replaying the real engine transitions, so the session's + history/answers are exactly what production would have produced. + """ + + def _make(*, step: str = TYPE_EPISODE, type_episode: str = "main") -> dict: + session = upload_file_engine.create_session() + if step in (MP3, TEMPLATE): + upload_file_engine.submit(session, type_episode) # type_episode -> mp3 + if step == TEMPLATE: + upload_file_engine.submit(session, "stub_file_id") # mp3 -> template + return {SESSION_KEY: session.to_dict()} + + return _make diff --git a/tests/unit/handlers/podcast_handler/test_cancel.py b/tests/unit/handlers/podcast_handler/test_cancel.py index b003a37..01d50a7 100644 --- a/tests/unit/handlers/podcast_handler/test_cancel.py +++ b/tests/unit/handlers/podcast_handler/test_cancel.py @@ -3,35 +3,32 @@ from aiogram_tests.types.dataset import MESSAGE, USER from config import LANGUAGES -from filters.dispatcher_filters import ContextButton -from forms.upload_file import UploadFile +from forms.upload_file import MP3 from handlers.podcast_handler import cancel from services import context +from utils.dialog import SESSION_KEY @pytest.mark.asyncio @pytest.mark.parametrize("username", ["test_user", "test_of_test"]) @pytest.mark.parametrize("language", LANGUAGES) -async def test_cancel_command(language, username, handler_factory, bot_factory, state_context_factory): - # Настройка обработчика и команды для cancel - handler_func = cancel - command = ContextButton("cancel") - state = UploadFile.type_episode - - # Создание обработчика, бота и контекста с использованием фабрик - handler = handler_factory(handler_func, command=command, state=state) +async def test_cancel_command( + language, username, handler_factory, bot_factory, state_context_factory, dialog_state, session_state_data +): + # An upload is in progress (session on some step) when the user cancels. + handler = handler_factory(cancel, state=dialog_state, state_data=session_state_data(step=MP3)) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="Отмена", from_user=user) - # Выполняем обработчик cancel calls = await bot.query(message=msg) state_context = await state_context_factory(handler, message=msg) - # Проверка отправленного сообщения и сброса состояния FSM assert len(calls.send_message) == 1 sent_message = calls.send_message.fetchone() - expected_text = context[language].canceled - assert sent_message.text == expected_text + assert sent_message.text == context[language].canceled assert sent_message.reply_markup == ReplyKeyboardRemove(remove_keyboard=True) + + # Cancelling wipes the dialog session and the FSM state. + assert SESSION_KEY not in (await state_context.get_data()), "Dialog session was not cleared on cancel" assert (await state_context.get_state()) is None diff --git a/tests/unit/handlers/podcast_handler/test_get_MP3.py b/tests/unit/handlers/podcast_handler/test_get_MP3.py index 1d0e4dd..3d17fba 100644 --- a/tests/unit/handlers/podcast_handler/test_get_MP3.py +++ b/tests/unit/handlers/podcast_handler/test_get_MP3.py @@ -7,9 +7,10 @@ from aiogram_tests.types.dataset import AUDIO, MESSAGE, USER from config import LANGUAGES -from forms.upload_file import UploadFile +from forms.upload_file import MP3, TEMPLATE, upload_file_engine from handlers.podcast_handler import get_MP3 from services import context, keyboards +from utils.dialog import SESSION_KEY @pytest.fixture @@ -52,18 +53,18 @@ async def test_get_MP3_handler( state_context_factory, configure_paths, mock_get_last_post_id, + dialog_state, + session_state_data, ): files_path, podcast_path = configure_paths - handler_func = get_MP3 - state = UploadFile.mp3 mock_file_id = "test_file_id" - state_data = {"type_episode": "main"} + # Session sits on the mp3 step with the episode type already chosen. + state_data = session_state_data(step=MP3, type_episode="main") test_mp3_file = files_path / "test_delete.mp3" test_mp3_file.touch() - # Общие настройки для бота, пользователя, сообщения и состояния - handler = handler_factory(handler_func, state=state, state_data=state_data) + handler = handler_factory(get_MP3, state=dialog_state, state_data=state_data) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) audio = AUDIO.as_object(file_id=mock_file_id) @@ -102,10 +103,10 @@ async def test_get_MP3_handler( assert not test_mp3_file.exists(), "Temporary MP3 file was not deleted" assert all(item.suffix != ".mp3" for item in files_path.iterdir()), "Previous MP3 files were not deleted" - # Проверка изменения состояния FSM - assert await state_context.get_state() == UploadFile.template, ( - "FSM state did not update to UploadFile.template as expected" - ) + # The engine recorded the uploaded file and advanced to the template step. + session = upload_file_engine.restore_session((await state_context.get_data())[SESSION_KEY]) + assert session.answers[MP3] == [mock_file_id] + assert upload_file_engine.current_step(session).id == TEMPLATE # Проверка текста сообщения и клавиатуры number_last_episode = "43" # так как `get_last_post_ID` вернул 42 diff --git a/tests/unit/handlers/podcast_handler/test_get_type.py b/tests/unit/handlers/podcast_handler/test_get_type.py index 0ba1acd..e2a06eb 100644 --- a/tests/unit/handlers/podcast_handler/test_get_type.py +++ b/tests/unit/handlers/podcast_handler/test_get_type.py @@ -2,15 +2,16 @@ from aiogram_tests.types.dataset import MESSAGE, USER from config import LANGUAGES -from forms.upload_file import UploadFile +from forms.upload_file import MP3, TYPE_EPISODE, upload_file_engine from handlers.podcast_handler import get_type from services import context, keyboards +from utils.dialog import SESSION_KEY @pytest.mark.asyncio @pytest.mark.parametrize("username", ["test_of_test"]) @pytest.mark.parametrize("language", LANGUAGES) -@pytest.mark.parametrize("type_episode_key", ["main_episode", "aftershow_episode"]) +@pytest.mark.parametrize("type_episode_key", ["main_episode", "episode_aftershow"]) async def test_get_type_handler( username, language, @@ -18,41 +19,35 @@ async def test_get_type_handler( handler_factory, bot_factory, state_context_factory, + dialog_state, + session_state_data, ): - # Set up handler for get_type - handler_func = get_type - state = UploadFile.type_episode - typeEpisode = context[language][type_episode_key] - - # Create handler, bot, and context - handler = handler_factory(handler_func, state=state) - + # Session is freshly created, sitting on the type_episode step. + handler = handler_factory( + get_type, + state=dialog_state, + state_data=session_state_data(step=TYPE_EPISODE), + ) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) + msg = MESSAGE.as_object(text=context[language][type_episode_key], from_user=user) - # Simulate a message from the user - msg = MESSAGE.as_object(text=typeEpisode, from_user=user) calls = await bot.query(message=msg) state_context = await state_context_factory(handler, message=msg) - typeEpisode = typeEpisode.lower() - # Verify FSM state update and sent message - state_data = await state_context.get_data() - expected_type = type_episode_key.replace("_episode", "") - assert state_data.get("type_episode") == expected_type, "Expected typeEpisode to be set in state data" - assert (await state_context.get_state()) == UploadFile.mp3, ( - "FSM state did not update to UploadFile.mp3 as expected" - ) + expected_type = "main" if type_episode_key == "main_episode" else "aftershow" + + # The engine stored the choice and advanced the session to the mp3 step. + session = upload_file_engine.restore_session((await state_context.get_data())[SESSION_KEY]) + assert session.answers[TYPE_EPISODE] == expected_type + assert upload_file_engine.current_step(session).id == MP3 - assert len(calls.send_message) == 1, "Expected one message to be sent" + assert len(calls.send_message) == 1 sent_message = calls.send_message.fetchone() # ``ask_mp3`` interpolates ``type_episode`` / ``type_episode_text`` from the # caller's locals (see services.context). Mirror what the handler computes # so the expected text matches across locales. type_episode = expected_type # noqa: F841 type_episode_text = "основной эпизод" if expected_type == "main" else "эпизод послешоу" # noqa: F841 - expected_text = context[language].ask_mp3 - assert sent_message.text == expected_text, "Sent message text does not match expected text" - assert sent_message.reply_markup == keyboards["podcast_handler"][language].cancel, ( - "Keyboard does not match expected 'cancel' keyboard" - ) + assert sent_message.text == context[language].ask_mp3 + assert sent_message.reply_markup == keyboards["podcast_handler"][language].cancel diff --git a/tests/unit/handlers/podcast_handler/test_set_template.py b/tests/unit/handlers/podcast_handler/test_set_template.py index 732c87d..c360975 100644 --- a/tests/unit/handlers/podcast_handler/test_set_template.py +++ b/tests/unit/handlers/podcast_handler/test_set_template.py @@ -8,9 +8,10 @@ from aiogram_tests.requester import Calls from aiogram_tests.types.dataset import MESSAGE, USER -from forms.upload_file import UploadFile +from forms.upload_file import TEMPLATE from handlers.podcast_handler import set_template from services import context, keyboards +from utils.dialog import SESSION_KEY @pytest.fixture @@ -54,15 +55,17 @@ async def test_set_template( type_episode, username, language, + dialog_state, + session_state_data, ): - state_data = {"type_episode": type_episode} + # Session sits on the template step; the episode type comes from its answers. + state_data = session_state_data(step=TEMPLATE, type_episode=type_episode) # Настроим mock-ответ от validate_template для успешного кейса valid_info = {"number": "42", "title": "Podcast Episode Title"} mock_validate_template.return_value = valid_info - # Создаём моки для бота, пользователя, сообщения и состояния - handler = handler_factory(set_template, state=UploadFile.template, state_data=state_data) + handler = handler_factory(set_template, state=dialog_state, state_data=state_data) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="Some valid template text", from_user=user) @@ -111,8 +114,11 @@ async def test_set_template( else keyboards["podcast_handler"][language].audio_menu_post ) - # Проверяем, что состояние было очищено + # On success the dialog is finished and its session is + # dropped from FSM storage. state_context = await state_context_factory(handler, message=msg) + data = await state_context.get_data() + assert SESSION_KEY not in data, "Dialog session was not cleared" assert await state_context.get_state() is None, "State was not cleared" @@ -127,14 +133,15 @@ async def test_set_template_invalid_input( mock_validate_template, username, language, + dialog_state, + session_state_data, ): - state_data = {"type_episode": "main"} + state_data = session_state_data(step=TEMPLATE, type_episode="main") # Устанавливаем mock-ответ validate_template как None для проверки обработки невалидного ввода mock_validate_template.return_value = None - # Создаём моки для бота, пользователя, сообщения и состояния - handler = handler_factory(set_template, state=UploadFile.template, state_data=state_data) + handler = handler_factory(set_template, state=dialog_state, state_data=state_data) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="Invalid template", from_user=user) @@ -152,3 +159,7 @@ async def test_set_template_invalid_input( # Проверяем, что никакой файл не был отправлен attrs = calls._get_attributes() assert "send_audio" not in attrs, "Audio should not be sent on invalid input" + + # The dialog stays on the template step so the user can retry. + state_context = await state_context_factory(handler, message=msg) + assert SESSION_KEY in (await state_context.get_data()), "Session should survive an invalid template" diff --git a/tests/unit/handlers/podcast_handler/test_start.py b/tests/unit/handlers/podcast_handler/test_start.py index d698cc8..5b41db0 100644 --- a/tests/unit/handlers/podcast_handler/test_start.py +++ b/tests/unit/handlers/podcast_handler/test_start.py @@ -3,34 +3,32 @@ from aiogram_tests.types.dataset import MESSAGE, USER from config import LANGUAGES -from forms.upload_file import UploadFile +from forms.upload_file import TYPE_EPISODE, upload_file_engine from handlers.podcast_handler import start from services import context +from utils.dialog import SESSION_KEY @pytest.mark.asyncio @pytest.mark.parametrize("username", ["test_of_test"]) @pytest.mark.parametrize("language", LANGUAGES) async def test_start_command(language, username, handler_factory, bot_factory, state_context_factory): - # Настройка обработчика и команды start - handler_func = start - command = Command(commands=["start"]) - - # Создание бота и контекста с использованием фабрик - handler = handler_factory(handler_func, command=command) + handler = handler_factory(start, command=Command(commands=["start"])) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="/start", from_user=user) - # Выполняем команду /start calls = await bot.query(message=msg) state_context = await state_context_factory(handler, message=msg) - # Проверка отправленного сообщения + # The prompt is unchanged. assert len(calls.send_message) == 1 - sent_message = calls.send_message.fetchone() - expected_text = context[language].ask_typeEpisode - assert sent_message.text == expected_text + assert calls.send_message.fetchone().text == context[language].ask_typeEpisode - # Проверка установки состояния FSM - assert (await state_context.get_state()) == UploadFile.type_episode + # /start now opens a dialog_engine session sitting on the first step, + # instead of setting an aiogram FSM state. + data = await state_context.get_data() + assert SESSION_KEY in data, "start() should create a dialog session in FSM data" + session = upload_file_engine.restore_session(data[SESSION_KEY]) + assert session.is_active + assert upload_file_engine.current_step(session).id == TYPE_EPISODE diff --git a/tests/unit/utils/dialog/test_dialog.py b/tests/unit/utils/dialog/test_dialog.py new file mode 100644 index 0000000..b76a3c5 --- /dev/null +++ b/tests/unit/utils/dialog/test_dialog.py @@ -0,0 +1,55 @@ +"""Tests for the aiogram <-> dialog_engine glue in ``utils.dialog``.""" + +import pytest +from aiogram.fsm.context import FSMContext +from aiogram.fsm.storage.base import StorageKey +from aiogram.fsm.storage.memory import MemoryStorage + +from forms.upload_file import MP3, TYPE_EPISODE, upload_file_engine +from utils.dialog import SESSION_KEY, load_session, save_session, start_dialog + + +@pytest.fixture +def state() -> FSMContext: + storage = MemoryStorage() + key = StorageKey(bot_id=1, chat_id=1, user_id=1) + return FSMContext(storage=storage, key=key) + + +@pytest.mark.asyncio +async def test_load_session_returns_none_when_empty(state): + assert await load_session(state, upload_file_engine) is None + + +@pytest.mark.asyncio +async def test_start_dialog_creates_and_persists_session(state): + session = await start_dialog(state, upload_file_engine) + + assert upload_file_engine.current_step(session).id == TYPE_EPISODE + assert SESSION_KEY in (await state.get_data()) + + restored = await load_session(state, upload_file_engine) + assert restored is not None + assert upload_file_engine.current_step(restored).id == TYPE_EPISODE + + +@pytest.mark.asyncio +async def test_save_session_round_trips_progress(state): + session = await start_dialog(state, upload_file_engine) + upload_file_engine.submit(session, "main") # advance to mp3 + await save_session(state, session) + + restored = await load_session(state, upload_file_engine) + assert restored.answers[TYPE_EPISODE] == "main" + assert upload_file_engine.current_step(restored).id == MP3 + + +@pytest.mark.asyncio +async def test_save_session_preserves_other_fsm_data(state): + await state.update_data(unrelated="keep-me") + session = await start_dialog(state, upload_file_engine) + await save_session(state, session) + + data = await state.get_data() + assert data["unrelated"] == "keep-me" + assert SESSION_KEY in data From 39294677097230e1a16203e168574ae99ed1f423 Mon Sep 17 00:00:00 2001 From: k0te1ch Date: Fri, 5 Jun 2026 16:56:58 +0300 Subject: [PATCH 2/6] ci: install poetry git deps via system git client Poetry's bundled dulwich client cannot install the dialog-engine git dependency ("No module named dulwich.worktree"). Switch the bot env install to the system git binary in CI and the Docker build, and add git to the Docker base image. --- .github/workflows/ci.yml | 3 +++ app/bot/Dockerfile | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1e499d..0dc0c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: working-directory: app/bot run: | poetry config virtualenvs.create false + # Use the system git binary for git dependencies; Poetry's bundled + # dulwich client fails to install them ("No module named dulwich.worktree"). + poetry config system-git-client true poetry install --with testing,dev --no-root - name: Run tests # Publisher microservice tests need each service's own deps (asyncssh, diff --git a/app/bot/Dockerfile b/app/bot/Dockerfile index 5c36944..330e5af 100644 --- a/app/bot/Dockerfile +++ b/app/bot/Dockerfile @@ -8,10 +8,12 @@ ENV PYTHONUNBUFFERED=1 # Рабочая директория WORKDIR /app -# Обновляем системные пакеты и устанавливаем необходимые утилиты +# Обновляем системные пакеты и устанавливаем необходимые утилиты. +# git нужен Poetry для установки зависимостей из git-репозиториев +# (system-git-client ниже) — встроенный dulwich-клиент на этом ломается. RUN apt-get update && \ apt-get install -y --no-install-recommends \ - ca-certificates curl locales && \ + ca-certificates curl git locales && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* @@ -31,6 +33,7 @@ COPY app/bot/pyproject.toml app/bot/poetry.lock /app/ # Настраиваем Poetry для работы без виртуальных окружений RUN poetry config virtualenvs.create false && \ + poetry config system-git-client true && \ poetry install --only main --no-root # Копируем исходный код From a14bf3c6126de40e87db656bba89379c41b63693 Mon Sep 17 00:00:00 2001 From: k0te1ch Date: Fri, 5 Jun 2026 17:02:34 +0300 Subject: [PATCH 3/6] ci: pin dulwich>=0.24 so poetry can install git deps Recent Poetry uses dulwich's new WorkTree API (module dulwich.worktree, dulwich>=0.24) for git dependencies; an older dulwich on the runner/base image breaks installing dialog-engine. Pin it when installing Poetry in CI and the Docker build. Replaces the earlier system-git-client attempt, which did not take effect. --- .github/workflows/ci.yml | 8 ++++---- app/bot/Dockerfile | 13 ++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dc0c89..5b95315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,14 +39,14 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Poetry - run: python -m pip install --upgrade pip poetry + # dulwich is pinned because Poetry's git-dependency client needs the new + # WorkTree API (dulwich>=0.24, module ``dulwich.worktree``); an older + # dulwich on the runner breaks installing the dialog-engine git dep. + run: python -m pip install --upgrade pip poetry "dulwich>=0.24.0" - name: Install bot dependencies (system env, mirrors Dockerfile) working-directory: app/bot run: | poetry config virtualenvs.create false - # Use the system git binary for git dependencies; Poetry's bundled - # dulwich client fails to install them ("No module named dulwich.worktree"). - poetry config system-git-client true poetry install --with testing,dev --no-root - name: Run tests # Publisher microservice tests need each service's own deps (asyncssh, diff --git a/app/bot/Dockerfile b/app/bot/Dockerfile index 330e5af..bff24f2 100644 --- a/app/bot/Dockerfile +++ b/app/bot/Dockerfile @@ -8,12 +8,10 @@ ENV PYTHONUNBUFFERED=1 # Рабочая директория WORKDIR /app -# Обновляем системные пакеты и устанавливаем необходимые утилиты. -# git нужен Poetry для установки зависимостей из git-репозиториев -# (system-git-client ниже) — встроенный dulwich-клиент на этом ломается. +# Обновляем системные пакеты и устанавливаем необходимые утилиты RUN apt-get update && \ apt-get install -y --no-install-recommends \ - ca-certificates curl git locales && \ + ca-certificates curl locales && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* @@ -24,16 +22,17 @@ RUN sed -i 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \ ENV LANG=ru_RU.UTF-8 ENV LC_ALL=ru_RU.UTF-8 -# Устанавливаем Poetry и необходимые инструменты сборки +# Устанавливаем Poetry и необходимые инструменты сборки. +# dulwich>=0.24 нужен git-клиенту Poetry (новый WorkTree API, +# модуль dulwich.worktree) для установки dialog-engine из git. RUN python -m pip install --upgrade pip setuptools wheel && \ - python -m pip install --upgrade poetry + python -m pip install --upgrade poetry "dulwich>=0.24.0" # Копируем файлы зависимостей и устанавливаем их COPY app/bot/pyproject.toml app/bot/poetry.lock /app/ # Настраиваем Poetry для работы без виртуальных окружений RUN poetry config virtualenvs.create false && \ - poetry config system-git-client true && \ poetry install --only main --no-root # Копируем исходный код From 9e4bc361a48cda3ca882445d277abbbe36efeeb0 Mon Sep 17 00:00:00 2001 From: k0te1ch Date: Fri, 5 Jun 2026 17:08:08 +0300 Subject: [PATCH 4/6] ci: install only the testing group for the test job The dev group pulls poetry-plugin-up, which drags in an old poetry and dulwich<0.22. With virtualenvs.create false that dulwich overwrites Poetry's own client during install and breaks fetching the dialog-engine git dependency ("No module named dulwich.worktree"). Move pytest-cov to the testing group and install only that group in CI. Reverts the earlier dulwich/system-git-client workarounds; the Docker build only installs the main group, so it was never affected. --- .github/workflows/ci.yml | 11 ++++++----- app/bot/Dockerfile | 6 ++---- app/bot/poetry.lock | 6 +++--- app/bot/pyproject.toml | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b95315..715f826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,15 +39,16 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Poetry - # dulwich is pinned because Poetry's git-dependency client needs the new - # WorkTree API (dulwich>=0.24, module ``dulwich.worktree``); an older - # dulwich on the runner breaks installing the dialog-engine git dep. - run: python -m pip install --upgrade pip poetry "dulwich>=0.24.0" + run: python -m pip install --upgrade pip poetry - name: Install bot dependencies (system env, mirrors Dockerfile) working-directory: app/bot + # Only the testing group: the dev group pulls poetry-plugin-up -> an old + # poetry -> dulwich<0.22, which (with virtualenvs.create false) clobbers + # Poetry's own dulwich mid-install and breaks fetching the dialog-engine + # git dependency. Tests need pytest-cov, which lives in the testing group. run: | poetry config virtualenvs.create false - poetry install --with testing,dev --no-root + poetry install --with testing --no-root - name: Run tests # Publisher microservice tests need each service's own deps (asyncssh, # aioprometheus, ...) which aren't in the bot env — they run in the diff --git a/app/bot/Dockerfile b/app/bot/Dockerfile index bff24f2..5c36944 100644 --- a/app/bot/Dockerfile +++ b/app/bot/Dockerfile @@ -22,11 +22,9 @@ RUN sed -i 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \ ENV LANG=ru_RU.UTF-8 ENV LC_ALL=ru_RU.UTF-8 -# Устанавливаем Poetry и необходимые инструменты сборки. -# dulwich>=0.24 нужен git-клиенту Poetry (новый WorkTree API, -# модуль dulwich.worktree) для установки dialog-engine из git. +# Устанавливаем Poetry и необходимые инструменты сборки RUN python -m pip install --upgrade pip setuptools wheel && \ - python -m pip install --upgrade poetry "dulwich>=0.24.0" + python -m pip install --upgrade poetry # Копируем файлы зависимостей и устанавливаем их COPY app/bot/pyproject.toml app/bot/poetry.lock /app/ diff --git a/app/bot/poetry.lock b/app/bot/poetry.lock index 2acb541..b810814 100644 --- a/app/bot/poetry.lock +++ b/app/bot/poetry.lock @@ -851,7 +851,7 @@ version = "7.13.5" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["testing"] files = [ {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, @@ -2946,7 +2946,7 @@ version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["testing"] files = [ {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, @@ -3867,4 +3867,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.15" -content-hash = "7be49509b6490f44accfd9e1ad908ad2de8df455dd46dd128227741c15e2e2fd" +content-hash = "a91f41bb744b9c3eb03c33a0bcf147cc9b850e5cb1f857f75a3bed5c32011651" diff --git a/app/bot/pyproject.toml b/app/bot/pyproject.toml index f3f273a..e08b785 100644 --- a/app/bot/pyproject.toml +++ b/app/bot/pyproject.toml @@ -43,6 +43,7 @@ pytest = "^8.3.2" pytest-asyncio = "^0.24.0" aiogram-testing = "^1.1.0" responses = "^0.23.1" +pytest-cov = "^5.0.0" [tool.poetry.group.e2e] @@ -55,7 +56,6 @@ tgtest = {git = "https://github.com/k0te1ch/tgtest"} [tool.poetry.group.dev.dependencies] ruff = "^0.11" poetry-plugin-up = "^0.7.1" -pytest-cov = "^5.0.0" pytest = "^8.3.2" From fb88364fbd7adb18b569f634a89b59c00d962621 Mon Sep 17 00:00:00 2001 From: k0te1ch Date: Fri, 5 Jun 2026 17:09:53 +0300 Subject: [PATCH 5/6] ci: use --only main,testing to exclude the dev group --with adds optional groups but non-optional groups (dev) still install by default, so the dev group's poetry-plugin-up kept dragging in dulwich<0.22 and breaking the dialog-engine git install. --only restricts the install to exactly main + testing. --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 715f826..fb75f05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,13 +42,15 @@ jobs: run: python -m pip install --upgrade pip poetry - name: Install bot dependencies (system env, mirrors Dockerfile) working-directory: app/bot - # Only the testing group: the dev group pulls poetry-plugin-up -> an old - # poetry -> dulwich<0.22, which (with virtualenvs.create false) clobbers - # Poetry's own dulwich mid-install and breaks fetching the dialog-engine - # git dependency. Tests need pytest-cov, which lives in the testing group. + # --only main,testing: the dev group (a non-optional group, so installed + # by default) pulls poetry-plugin-up -> an old poetry -> dulwich<0.22, + # which with virtualenvs.create false clobbers Poetry's own dulwich + # mid-install and breaks fetching the dialog-engine git dependency + # ("No module named dulwich.worktree"). Tests need pytest-cov, which now + # lives in the testing group. run: | poetry config virtualenvs.create false - poetry install --with testing --no-root + poetry install --only main,testing --no-root - name: Run tests # Publisher microservice tests need each service's own deps (asyncssh, # aioprometheus, ...) which aren't in the bot env — they run in the From 2a5174be9dbf0f3487b9c1bee501ab31965499f2 Mon Sep 17 00:00:00 2001 From: k0te1ch Date: Sat, 6 Jun 2026 03:45:31 +0300 Subject: [PATCH 6/6] ci: report the test job as context 'test' for the ruleset The main protection ruleset requires a status check named 'test', but the single-value matrix made the job report as 'test (3.12)', leaving the requirement permanently unmet. Drop the matrix so the context matches. --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb75f05..5e9526e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,16 +28,16 @@ jobs: - name: Ruff format check run: ruff format --check "$GITHUB_WORKSPACE/app" "$GITHUB_WORKSPACE/tests" + # Job id is the status-check context required by the "main protection" + # ruleset ("test"). A matrix would report it as "test (3.12)" and never + # satisfy that requirement, so keep it a single un-matrixed job. test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Install Poetry run: python -m pip install --upgrade pip poetry - name: Install bot dependencies (system env, mirrors Dockerfile)