diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..ebe5576 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,30 @@ +name: Python CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + 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 }} + - name: Install Poetry + run: pipx install poetry + - name: Install dependencies + run: poetry install --all-extras + - name: Run pre-commit hooks + run: poetry run pre-commit run --all-files --show-diff-on-failure + - name: Run tests + run: poetry run pytest diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..58d6a3a --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,19 @@ +name: release-please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..25402ee --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-toml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.11 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.4.1 + hooks: + - id: commitizen + stages: [commit-msg] diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..413b5d6 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# DialogEngine + +[![Python CI](https://github.com/k0te1ch/DialogEngine/actions/workflows/python-app.yml/badge.svg)](https://github.com/k0te1ch/DialogEngine/actions/workflows/python-app.yml) +[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +`DialogEngine` — лёгкий движок многошаговых диалогов на чистом Python (stdlib, без +обязательных зависимостей). Помогает строить сложные form-driven сценарии и +Telegram-ботов без спагетти из хендлеров: схема диалога описывается данными, а +движок управляет навигацией, валидацией и состоянием сессии. + +## Возможности + +- Описание диалога данными (`dict` / JSON / список шагов) — без хардкода переходов. +- Условные переходы между шагами (`next` по значению ответа). +- Встроенная валидация ответов + кастомные валидаторы, синхронные и асинхронные. +- Резолверы текста с подстановкой из накопленных ответов (`{name}` и т.п.). +- Управление сессией: статусы `IN_PROGRESS / COMPLETED / CANCELLED`. +- Опциональные extras: `pydantic`-валидация, загрузка схем из YAML, интеграция с `aiogram`. + +## Установка + +```bash +pip install dialog-engine +# с дополнительными возможностями: +pip install "dialog-engine[validation,yaml,aiogram]" +``` + +Для разработки используется [Poetry](https://python-poetry.org/): + +```bash +poetry install --all-extras +``` + +## Быстрый старт + +```python +from dialog_engine import DialogEngine + +engine = DialogEngine.from_list([ + {"id": "name", "type": "text", "text": "Как вас зовут?"}, + {"id": "age", "type": "number", "text": "Сколько вам лет?", "min": 1}, +]) + +session = engine.create_session() + +while (step := engine.current_step(session)) is not None: + answer = input(engine.resolve_text(step, session) + " ") + try: + engine.submit(session, answer) + except ValueError as exc: + print("Ошибка:", exc) + +print("Готово!", session.answers) +``` + +## Асинхронный режим + +Движок поддерживает асинхронные валидаторы и резолверы текста через +`async_submit` / `async_resolve_text`. Полный пример — +[examples/async_validators_example.py](examples/async_validators_example.py). + +## Основной API + +- `DialogEngine` — загрузка схемы и управление потоком диалога. +- `DialogSession` / `SessionStatus` — состояние одного запуска диалога. +- `DialogStep` / `StepType` — описание шага и его тип. +- `validate` — встроенная валидация ответов. +- `DialogError`, `ValidationError`, `StepNotFoundError` — иерархия исключений. + +## Разработка + +```bash +poetry install --all-extras +poetry run pre-commit install --hook-type pre-commit --hook-type commit-msg +poetry run pytest +poetry run pre-commit run --all-files +``` + +Коммиты — по [Conventional Commits](https://www.conventionalcommits.org/); +формат проверяет хук `commitizen`. Релизы, теги и `CHANGELOG.md` полностью +автоматизированы через [release-please](https://github.com/googleapis/release-please). +Полный гайд с диаграммами — [docs/WORKFLOW.md](docs/WORKFLOW.md). + +## Структура проекта + +- `dialog_engine/` — исходный код библиотеки. +- `tests/` — набор тестов (`pytest`). +- `examples/` — примеры использования. +- `docs/WORKFLOW.md` — гайд по веткам, коммитам и релизам. +- `.github/workflows/` — CI и release-please. + +## Лицензия + +[MIT](LICENSE) diff --git a/dialog_engine/__init__.py b/dialog_engine/__init__.py new file mode 100644 index 0000000..456e6f3 --- /dev/null +++ b/dialog_engine/__init__.py @@ -0,0 +1,61 @@ +"""dialog_engine — universal multi-step dialog engine. + +Quick start:: + + from dialog_engine import DialogEngine + + engine = DialogEngine.from_file("dialogs/onboarding.json") + session = engine.create_session() + + while (step := engine.current_step(session)) is not None: + answer = input(engine.resolve_text(step, session) + " ") + try: + engine.submit(session, answer) + except ValidationError as exc: + print("Error:", exc) + +Public API +---------- +Classes: + DialogEngine — loads a dialog schema, drives navigation + DialogSession — mutable run-time state for one dialog instance + DialogStep — a single step in the dialog schema + SessionStatus — enum: IN_PROGRESS / COMPLETED / CANCELLED + +Exceptions: + DialogError — base exception + StepNotFoundError — unknown step ID referenced + ValidationError — submitted answer fails validation + +Types: + StepType — Literal union of all supported step type strings + TextResolver — Callable used by the engine to resolve display text +""" + +from .engine import DialogEngine, TextResolver +from .exceptions import DialogError, StepNotFoundError, ValidationError +from .session import DialogSession, SessionStatus +from .step import DialogStep, StepType +from .validators import validate + +__version__ = "0.0.0" # x-release-please-version + +__all__ = [ + # Engine + "DialogEngine", + "TextResolver", + # Session + "DialogSession", + "SessionStatus", + # Step + "DialogStep", + "StepType", + # Validators + "validate", + # Exceptions + "DialogError", + "StepNotFoundError", + "ValidationError", + # Metadata + "__version__", +] diff --git a/dialog_engine/engine.py b/dialog_engine/engine.py new file mode 100644 index 0000000..59610d7 --- /dev/null +++ b/dialog_engine/engine.py @@ -0,0 +1,444 @@ +"""DialogEngine — orchestrates a dialog flow.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +from .exceptions import DialogError, StepNotFoundError +from .session import DialogSession, SessionStatus +from .step import DialogStep +from .validators import async_validate as _async_validate +from .validators import sync_validate as _validate + +# ── Text resolver type ──────────────────────────────────────────────────────── + +TextResolver = Callable[[str, dict[str, Any]], str] +AsyncTextResolver = Callable[[str, dict[str, Any]], Awaitable[str]] +"""A callable that resolves a step's ``text`` field for display. + +Signature:: + + def resolver(key: str, context: dict[str, Any]) -> str: ... + +*key* is ``step.text``; *context* carries the session answers so +the resolver can interpolate dynamic values. + +The default resolver returns *key* unchanged, which works well when +``text`` already contains the full display string. +""" + + +def _passthrough_resolver(key: str, _ctx: dict[str, Any]) -> str: + return key + + +# ── Engine ──────────────────────────────────────────────────────────────────── + + +class DialogEngine: + """Universal multi-step dialog engine. + + The engine owns the *schema* (steps + branching logic) and is + stateless with respect to individual runs. All mutable state lives + in :class:`~dialog_engine.DialogSession` objects. + + Typical usage:: + + engine = DialogEngine.from_file("dialogs/onboarding.json") + + session = engine.create_session() + step = engine.current_step(session) # → first step + print(engine.resolve_text(step, session)) # display the question + + next_step = engine.submit(session, "Alice") + # … repeat until next_step is None (dialog complete) + + JSON format + ----------- + Bare list:: + + [{"id": "name", "type": "text", "text": "Your name?"}, …] + + Wrapped dict (recommended — carries ``id`` and optional metadata):: + + { + "id": "onboarding", + "steps": [{"id": "name", "type": "text", "text": "Your name?"}, …] + } + + Branching example:: + + { + "id": "country", + "type": "choice", + "text": "Choose country", + "choices": {"RU": "Russia", "OTHER": "Other"}, + "next": {"RU": "passport_ru", "OTHER": "passport_other"} + } + + Use ``"_end"`` as a branch target to terminate the dialog early. + """ + + def __init__( + self, + steps: list[DialogStep], + dialog_id: str = "dialog", + text_resolver: TextResolver | AsyncTextResolver | None = None, + ) -> None: + if not steps: + raise DialogError("A dialog must have at least one step.") + self.dialog_id = dialog_id + self.steps = list(steps) + self._by_id: dict[str, int] = {s.id: i for i, s in enumerate(steps)} + self.text_resolver: TextResolver | AsyncTextResolver = ( + text_resolver or _passthrough_resolver + ) + self._is_async_resolver = asyncio.iscoroutinefunction(text_resolver) + + # ── Constructors ────────────────────────────────────────────────────────── + + @classmethod + def from_file( + cls, + path: str | Path, + text_resolver: TextResolver | None = None, + ) -> DialogEngine: + """Load a dialog from a JSON file. + + Supports both bare-list and wrapped-dict formats (see class docstring). + """ + path = Path(path) + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + dialog_id: str = raw.get("id", path.stem) + steps_data: list[dict] = raw["steps"] + else: + dialog_id = path.stem + steps_data = raw + return cls( + [DialogStep.from_dict(s) for s in steps_data], + dialog_id=dialog_id, + text_resolver=text_resolver, + ) + + @classmethod + def from_list( + cls, + data: list[dict], + dialog_id: str = "dialog", + text_resolver: TextResolver | None = None, + ) -> DialogEngine: + """Create a dialog from a list of step dicts.""" + return cls( + [DialogStep.from_dict(s) for s in data], + dialog_id=dialog_id, + text_resolver=text_resolver, + ) + + # ── Session management ──────────────────────────────────────────────────── + + def create_session(self, start_index: int = 0) -> DialogSession: + """Create a fresh session starting at *start_index*.""" + if not (0 <= start_index < len(self.steps)): + raise DialogError( + f"start_index {start_index} is out of range (0–{len(self.steps) - 1})." + ) + session = DialogSession(dialog_id=self.dialog_id) + session._history = [start_index] + return session + + def restore_session(self, data: dict[str, Any]) -> DialogSession: + """Restore a session from a previously serialised dict.""" + return DialogSession.from_dict(data) + + # ── Navigation ──────────────────────────────────────────────────────────── + + def current_step(self, session: DialogSession) -> DialogStep | None: + """Return the step the user is currently on, or ``None`` if finished.""" + if not session.is_active: + return None + return self.steps[session.current_index] + + def submit(self, session: DialogSession, value: Any) -> DialogStep | None: + """Submit an answer for the current step. + + Validates *value*, stores it in the session, and advances to the + next step. + + Returns: + The next :class:`~dialog_engine.DialogStep`, or ``None`` when + the dialog is complete. + + Raises: + :class:`~dialog_engine.ValidationError` if *value* is invalid. + :class:`~dialog_engine.DialogError` if the session is not active. + """ + self._assert_active(session) + step = self._require_current(session) + + cleaned = _validate(step, value) + session.answers[step.id] = cleaned + + next_idx = self._resolve_next_index(step, cleaned, session.current_index) + if next_idx is None: + session.status = SessionStatus.COMPLETED + return None + + session._history.append(next_idx) + return self.steps[next_idx] + + async def async_submit( + self, session: DialogSession, value: Any + ) -> DialogStep | None: + """Submit an answer for the current step asynchronously. + + Validates *value*, stores it in the session, and advances to the + next step. + + Returns: + The next :class:`~dialog_engine.DialogStep`, or ``None`` when + the dialog is complete. + + Raises: + :class:`~dialog_engine.ValidationError` if *value* is invalid. + :class:`~dialog_engine.DialogError` if the session is not active. + """ + self._assert_active(session) + step = self._require_current(session) + + cleaned = await _async_validate(step, value) + session.answers[step.id] = cleaned + + next_idx = self._resolve_next_index(step, cleaned, session.current_index) + if next_idx is None: + session.status = SessionStatus.COMPLETED + return None + + session._history.append(next_idx) + return self.steps[next_idx] + + def skip(self, session: DialogSession) -> DialogStep | None: + """Skip the current *optional* step (``required=False``). + + Returns: + The next step, or ``None`` if the dialog is complete. + + Raises: + :class:`~dialog_engine.DialogError` if the step is required. + """ + self._assert_active(session) + step = self._require_current(session) + if step.required: + raise DialogError(f"Step {step.id!r} is required and cannot be skipped.") + + session.answers[step.id] = None + next_idx = self._resolve_next_index(step, None, session.current_index) + if next_idx is None: + session.status = SessionStatus.COMPLETED + return None + + session._history.append(next_idx) + return self.steps[next_idx] + + async def async_skip(self, session: DialogSession) -> DialogStep | None: + """Skip the current *optional* step (``required=False``) asynchronously. + + Returns: + The next step, or ``None`` if the dialog is complete. + + Raises: + :class:`~dialog_engine.DialogError` if the step is required. + """ + self._assert_active(session) + step = self._require_current(session) + if step.required: + raise DialogError(f"Step {step.id!r} is required and cannot be skipped.") + + session.answers[step.id] = None + next_idx = self._resolve_next_index(step, None, session.current_index) + if next_idx is None: + session.status = SessionStatus.COMPLETED + return None + + session._history.append(next_idx) + return self.steps[next_idx] + + def back(self, session: DialogSession) -> DialogStep: + """Go back to the previous step and clear its stored answer. + + Raises: + :class:`~dialog_engine.DialogError` if already on the first step. + """ + self._assert_active(session) + if len(session._history) <= 1: + raise DialogError("Already on the first step.") + + # Drop the current (unanswered) step. + session._history.pop() + # The step we're returning to will be re-answered, so clear its value. + prev_step = self.steps[session._history[-1]] + session.answers.pop(prev_step.id, None) + return prev_step + + async def async_back(self, session: DialogSession) -> DialogStep: + """Go back to the previous step and clear its stored answer asynchronously. + + Raises: + :class:`~dialog_engine.DialogError` if already on the first step. + """ + self._assert_active(session) + if len(session._history) <= 1: + raise DialogError("Already on the first step.") + + # Drop the current (unanswered) step. + session._history.pop() + # The step we're returning to will be re-answered, so clear its value. + prev_step = self.steps[session._history[-1]] + session.answers.pop(prev_step.id, None) + return prev_step + + def jump_to(self, session: DialogSession, step_id: str) -> DialogStep: + """Jump directly to the step with the given *step_id*. + + Useful for edit flows where the user wants to revisit a specific step. + The step's previous answer is cleared so it can be re-submitted. + """ + self._assert_active(session) + idx = self._by_id.get(step_id) + if idx is None: + raise StepNotFoundError(step_id) + session._history.append(idx) + session.answers.pop(step_id, None) + return self.steps[idx] + + def cancel(self, session: DialogSession) -> None: + """Mark the session as cancelled.""" + session.status = SessionStatus.CANCELLED + + # ── Queries ─────────────────────────────────────────────────────────────── + + def resolve_text( + self, + step: DialogStep, + session: DialogSession | None = None, + ) -> str: + """Return the display text for *step* via the :attr:`text_resolver`. + + Passes session answers as context so the resolver can interpolate + dynamic values (e.g. ``"Hello {name}!"``). + """ + ctx: dict[str, Any] = session.answers if session is not None else {} + if self._is_async_resolver: + raise DialogError( + "Async text resolver requires calling async_resolve_text() instead" + ) + return self.text_resolver(step.text, ctx) + + async def async_resolve_text( + self, + step: DialogStep, + session: DialogSession | None = None, + ) -> str: + """Return the display text for *step* via the async :attr:`text_resolver`. + + Passes session answers as context so the resolver can interpolate + dynamic values (e.g. ``"Hello {name}!"``). + """ + ctx: dict[str, Any] = session.answers if session is not None else {} + if not self._is_async_resolver: + return self.resolve_text(step, session) + return await self.text_resolver(step.text, ctx) + + def get_step(self, index: int) -> DialogStep | None: + """Return the step at *index*, or ``None`` if out of range.""" + return self.steps[index] if 0 <= index < len(self.steps) else None + + def get_step_by_id(self, step_id: str) -> DialogStep: + """Return the step with the given ID. + + Raises :class:`~dialog_engine.StepNotFoundError` if not found. + """ + idx = self._by_id.get(step_id) + if idx is None: + raise StepNotFoundError(step_id) + return self.steps[idx] + + def progress(self, session: DialogSession) -> tuple[int, int]: + """Return ``(step_number, total)`` for display (e.g. "Step 2 of 5"). + + *step_number* is 1-based. + """ + return len(session._history), len(self.steps) + + def is_last(self, index: int) -> bool: + """``True`` if *index* refers to the last step.""" + return index >= len(self.steps) - 1 + + def total(self) -> int: + """Total number of steps in this dialog.""" + return len(self.steps) + + def to_dict(self) -> dict[str, Any]: + """Serialise the dialog schema to a plain dict.""" + return { + "id": self.dialog_id, + "steps": [s.to_dict() for s in self.steps], + } + + # ── Internal helpers ────────────────────────────────────────────────────── + + def _assert_active(self, session: DialogSession) -> None: + if not session.is_active: + raise DialogError(f"Session is {session.status.value}, not in-progress.") + + def _require_current(self, session: DialogSession) -> DialogStep: + step = self.current_step(session) + if step is None: + raise DialogError("No current step.") + return step + + def _resolve_next_index( + self, + step: DialogStep, + answer: Any, + current_index: int, + ) -> int | None: + """Compute the index of the next step. + + Returns ``None`` to signal end-of-dialog. + """ + if step.next is None: + # Sequential advancement. + nxt = current_index + 1 + return nxt if nxt < len(self.steps) else None + + if isinstance(step.next, str): + # Unconditional jump. + if step.next == "_end": + return None + return self._lookup(step.next) + + # Conditional branch dict. + branch_map: dict[str, str] = step.next + key = str(answer) if answer is not None else "_default" + target_id = branch_map.get(key) or branch_map.get("_default") + + if target_id is None: + # No matching branch → fall through sequentially. + nxt = current_index + 1 + return nxt if nxt < len(self.steps) else None + + if target_id == "_end": + return None + + return self._lookup(target_id) + + def _lookup(self, step_id: str) -> int: + idx = self._by_id.get(step_id) + if idx is None: + raise StepNotFoundError(step_id) + return idx diff --git a/dialog_engine/exceptions.py b/dialog_engine/exceptions.py new file mode 100644 index 0000000..d406a92 --- /dev/null +++ b/dialog_engine/exceptions.py @@ -0,0 +1,23 @@ +"""Exceptions for the dialog engine.""" + +from __future__ import annotations + + +class DialogError(Exception): + """Base exception for all dialog engine errors.""" + + +class StepNotFoundError(DialogError): + """Raised when a step ID cannot be resolved.""" + + def __init__(self, step_id: str) -> None: + super().__init__(f"Step {step_id!r} not found") + self.step_id = step_id + + +class ValidationError(DialogError): + """Raised when a submitted answer fails validation.""" + + def __init__(self, message: str, step_id: str | None = None) -> None: + super().__init__(message) + self.step_id = step_id diff --git a/dialog_engine/session.py b/dialog_engine/session.py new file mode 100644 index 0000000..d68a1ed --- /dev/null +++ b/dialog_engine/session.py @@ -0,0 +1,69 @@ +"""DialogSession — runtime state for one dialog run.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class SessionStatus(StrEnum): + """Lifecycle state of a dialog session.""" + + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +@dataclass +class DialogSession: + """Tracks the progress of a single dialog run. + + Attributes: + dialog_id: ID of the :class:`~dialog_engine.DialogEngine` that owns this session. + answers: Collected answers keyed by step ID. + status: Current lifecycle state. + + Internal: + _history: Stack of visited step indices. ``_history[-1]`` is always + the *current* (not yet answered) step. + """ + + dialog_id: str + answers: dict[str, Any] = field(default_factory=dict) + status: SessionStatus = SessionStatus.IN_PROGRESS + _history: list[int] = field(default_factory=list, repr=False) + + # ── Properties ──────────────────────────────────────────────────────────── + + @property + def current_index(self) -> int: + """Zero-based index of the step the user is currently on.""" + return self._history[-1] if self._history else 0 + + @property + def is_active(self) -> bool: + """``True`` while the session is in progress.""" + return self.status == SessionStatus.IN_PROGRESS + + # ── Serialisation ───────────────────────────────────────────────────────── + + def to_dict(self) -> dict[str, Any]: + """Serialise session state (e.g. for storage in Redis / DB).""" + return { + "dialog_id": self.dialog_id, + "answers": self.answers, + "history": list(self._history), + "status": self.status.value, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DialogSession: + """Restore a previously serialised session.""" + session = cls( + dialog_id=data["dialog_id"], + answers=dict(data.get("answers", {})), + status=SessionStatus(data.get("status", SessionStatus.IN_PROGRESS)), + ) + session._history = list(data.get("history", [])) + return session diff --git a/dialog_engine/step.py b/dialog_engine/step.py new file mode 100644 index 0000000..2bc2464 --- /dev/null +++ b/dialog_engine/step.py @@ -0,0 +1,106 @@ +"""DialogStep — a single unit in a dialog flow.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +# ── Public type alias ──────────────────────────────────────────────────────── + +StepType = Literal[ + "text", + "number", + "email", + "boolean", + "choice", + "multi_choice", + "photo", + "file", +] + +# ── Branching type ──────────────────────────────────────────────────────────── +# +# next: None → advance sequentially +# next: "step_id" → always jump to that step +# next: {"KEY": "step_id", "_default": "step_id"} +# → branch on the submitted answer key; +# use "_end" as a value to terminate the dialog. + +NextSpec = str | dict[str, str] | None + + +@dataclass +class DialogStep: + """A single step in a dialog flow. + + Attributes: + id: Unique identifier used for answer storage and branching. + type: Input type; determines which validator runs. + text: Display text or i18n key passed to the text resolver. + required: Whether the step must be answered (cannot be skipped). + choices: Mapping of key → display text for *choice* / *multi_choice* steps. + min: Lower bound. Meaning depends on type: + text → min character length + number → min numeric value + photo / file → min item count + multi_choice → min selected options + max: Upper bound (same semantics as *min*). + pattern: Regex pattern for *text* steps (applied via ``re.fullmatch``). + next: Branching spec (see module docstring). + meta: Arbitrary extra data; ignored by the engine. + """ + + id: str + type: StepType + text: str + required: bool = True + choices: dict[str, str] = field(default_factory=dict) + min: int | float | None = None + max: int | float | None = None + pattern: str | None = None + next: NextSpec = None + meta: dict[str, Any] = field(default_factory=dict) + + # ── Construction ────────────────────────────────────────────────────────── + + @classmethod + def from_dict(cls, data: dict) -> DialogStep: + """Create a step from a plain dict (e.g. parsed JSON). + + Accepted keys mirror the field names; legacy ``min_photos`` / + ``max_photos`` are also recognised for backward compatibility. + """ + return cls( + id=data["id"], + type=data["type"], + text=data["text"], + required=data.get("required", True), + choices=data.get("choices", {}), + min=data.get("min", data.get("min_photos")), + max=data.get("max", data.get("max_photos")), + pattern=data.get("pattern"), + next=data.get("next"), + meta=data.get("meta", {}), + ) + + def to_dict(self) -> dict[str, Any]: + """Serialise this step back to a plain dict.""" + d: dict[str, Any] = { + "id": self.id, + "type": self.type, + "text": self.text, + "required": self.required, + } + if self.choices: + d["choices"] = self.choices + if self.min is not None: + d["min"] = self.min + if self.max is not None: + d["max"] = self.max + if self.pattern is not None: + d["pattern"] = self.pattern + if self.next is not None: + d["next"] = self.next + if self.meta: + d["meta"] = self.meta + return d diff --git a/dialog_engine/validators.py b/dialog_engine/validators.py new file mode 100644 index 0000000..c98e14a --- /dev/null +++ b/dialog_engine/validators.py @@ -0,0 +1,200 @@ +"""Built-in answer validators for each step type. + +Each validator: + - accepts (value, step) where *value* is the raw user input + - returns a cleaned / normalised value on success + - raises ValidationError on failure +""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import Awaitable, Callable +from typing import Any + +from .exceptions import ValidationError +from .step import DialogStep + +# ── Per-type validators ─────────────────────────────────────────────────────── + + +def _validate_text(value: Any, step: DialogStep) -> str: + text = str(value).strip() + if not text: + if step.required: + raise ValidationError("Это поле обязательно для заполнения.", step.id) + return text + if step.min is not None and len(text) < int(step.min): + raise ValidationError( + f"Слишком короткий текст (минимум {int(step.min)} символов).", step.id + ) + if step.max is not None and len(text) > int(step.max): + raise ValidationError( + f"Слишком длинный текст (максимум {int(step.max)} символов).", step.id + ) + if step.pattern is not None and not re.fullmatch(step.pattern, text): + raise ValidationError("Текст не соответствует ожидаемому формату.", step.id) + return text + + +def _validate_number(value: Any, step: DialogStep) -> int | float: + try: + num = float(str(value).replace(",", ".")) + except (TypeError, ValueError): + raise ValidationError( + f"Ожидается число, получено: {value!r}.", step.id + ) from None + if step.min is not None and num < step.min: + raise ValidationError(f"Число должно быть не меньше {step.min}.", step.id) + if step.max is not None and num > step.max: + raise ValidationError(f"Число должно быть не больше {step.max}.", step.id) + return int(num) if num == int(num) else num + + +def _validate_email(value: Any, step: DialogStep) -> str: + text = str(value).strip().lower() + if not re.fullmatch(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text): + raise ValidationError( + f"Некорректный адрес электронной почты: {text!r}.", step.id + ) + return text + + +def _validate_boolean(value: Any, step: DialogStep) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + if isinstance(value, str): + if value.lower() in ("1", "true", "yes", "да", "y"): + return True + if value.lower() in ("0", "false", "no", "нет", "n"): + return False + raise ValidationError( + f"Ожидается булево значение (true/false/да/нет), получено: {value!r}.", step.id + ) + + +def _validate_choice(value: Any, step: DialogStep) -> str: + key = str(value) + if key not in step.choices: + valid = ", ".join(step.choices.keys()) + raise ValidationError( + f"Неверный вариант: {key!r}. Допустимые: {valid}.", step.id + ) + return key + + +def _validate_multi_choice(value: Any, step: DialogStep) -> list[str]: + if isinstance(value, str): + items = [v.strip() for v in value.split(",") if v.strip()] + elif isinstance(value, (list, tuple, set)): + items = [str(v) for v in value] + else: + raise ValidationError( + f"Ожидается список вариантов, получено: {value!r}.", step.id + ) + + invalid = [v for v in items if v not in step.choices] + if invalid: + valid = ", ".join(step.choices.keys()) + raise ValidationError( + f"Недопустимые варианты: {invalid}. Допустимые: {valid}.", step.id + ) + if step.min is not None and len(items) < int(step.min): + raise ValidationError(f"Выберите не менее {int(step.min)} вариантов.", step.id) + if step.max is not None and len(items) > int(step.max): + raise ValidationError(f"Выберите не более {int(step.max)} вариантов.", step.id) + return items + + +def _validate_media(value: Any, step: DialogStep, label: str) -> list[Any]: + items: list[Any] = value if isinstance(value, list) else [value] + min_count = int(step.min) if step.min is not None else 1 + max_count = int(step.max) if step.max is not None else 1 + if len(items) < min_count: + raise ValidationError( + f"Необходимо загрузить минимум {min_count} {label}.", step.id + ) + if len(items) > max_count: + raise ValidationError(f"Можно загрузить не более {max_count} {label}.", step.id) + return items + + +def _validate_photo(value: Any, step: DialogStep) -> list[Any]: + return _validate_media(value, step, "фото") + + +def _validate_file(value: Any, step: DialogStep) -> list[Any]: + return _validate_media(value, step, "файлов") + + +# ── Registry ────────────────────────────────────────────────────────────────── + +AsyncValidator = Callable[[Any, DialogStep], Awaitable[Any]] + +_VALIDATORS: dict[str, Any] = { + "text": _validate_text, + "number": _validate_number, + "email": _validate_email, + "boolean": _validate_boolean, + "choice": _validate_choice, + "multi_choice": _validate_multi_choice, + "photo": _validate_photo, + "file": _validate_file, +} + +_ASYNC_VALIDATORS: dict[str, AsyncValidator] = {} + + +def sync_validate(step: DialogStep, value: Any) -> Any: + """Validate *value* against *step*'s rules synchronously. + + Returns the cleaned value on success. + Raises :class:`ValidationError` on failure. + Unknown step types pass through without validation. + """ + is_empty = value is None or (isinstance(value, str) and not value.strip()) + if is_empty: + if step.required: + raise ValidationError("Это поле обязательно для заполнения.", step.id) + return value # optional → accept empty + + fn = _VALIDATORS.get(step.type) + if fn is None: + return value + return fn(value, step) + + +def validate(step: DialogStep, value: Any) -> Any: + """Validate *value* against *step*'s rules. + + Returns the cleaned value on success. + Raises :class:`ValidationError` on failure. + Unknown step types pass through without validation. + """ + return sync_validate(step, value) + + +async def async_validate(step: DialogStep, value: Any) -> Any: + """Validate *value* against *step*'s rules asynchronously. + + Returns the cleaned value on success. + Raises :class:`ValidationError` on failure. + Unknown step types pass through without validation. + """ + is_empty = value is None or (isinstance(value, str) and not value.strip()) + if is_empty: + if step.required: + raise ValidationError("Это поле обязательно для заполнения.", step.id) + return value # optional → accept empty + + fn = _ASYNC_VALIDATORS.get(step.type) or _VALIDATORS.get(step.type) + if fn is None: + return value + + if asyncio.iscoroutinefunction(fn): + return await fn(value, step) + else: + return fn(value, step) diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md new file mode 100644 index 0000000..420e618 --- /dev/null +++ b/docs/WORKFLOW.md @@ -0,0 +1,262 @@ +# Workflow: как это всё работает + +Документ описывает полный цикл — от первого `git commit` до GitHub Release — и +как организовать работу через feature-ветки. + +> Все диаграммы написаны на [Mermaid](https://mermaid.js.org/). GitHub +> рендерит их автоматически. + +--- + +## 1. Большая картина + +```mermaid +flowchart TD + A[Локальные правки] --> B[git commit] + B --> C{pre-commit hooks} + C -- ruff / ruff-format --> C + C -- commit-msg
commitizen --> D{Conventional
Commits?} + D -- нет --> X[Коммит отклонён
исправить сообщение] + D -- да --> E[Commit создан локально] + E --> F[git push в feature-ветку] + F --> G[Pull Request на GitHub] + G --> H[CI: python-app.yml
pre-commit + pytest] + H -- зелёный --> I[Merge в main] + I --> J[CI: release-please.yml] + J --> K[release-please открывает/
обновляет Release PR] + K -- merge Release PR --> L[Tag vX.Y.Z + GitHub Release
+ CHANGELOG.md] + + classDef bad fill:#fee,stroke:#c33; + class X bad; +``` + +Главное: **сообщения коммитов — это и есть «исходник» changelog'а**. Поэтому их +формат жёстко проверяется на этапе `git commit`, до того как код попадёт на +сервер, а release-please превращает их в версию и CHANGELOG. + +--- + +## 2. Conventional Commits — формат сообщений + +``` +()!: + + + +