Фреймворк представляет собой реализацию паттерна state machine, предназначенную для описания логики взаимодействия юзера с системой в виде графа состояний.
Примеры использования в папке examples. Примеры довольно базовые и будут дописываться. В частности examples/main.py - описание handler-ов и состояний, examples/config.py - пример конфига, examples/message.py - пример объявления типа результата.
Основными понятиями являются: контекст Context
, состояние State
, обработчик Handler
,
команда Command
.
Для запуска фреймворка необходим сделать две вещи - вызвать setup(...)
, и определить, в каком месте будет вызываться process(...)
.
Возьмем для примера абстрактного чат-бота:
from spockesman import process, setup, InvalidCommandException # импортируем то что нам надо
import config as spockesman_config # импортируем модуль с конфигурацией, его надо написать самому
setup(spockesman_config) # передаем конфигурацию в setup, это создаст необходимое окружение
class Bot(SomeChatBot):
# у нашего теоретического бота, on_message вызывается при поступлении сообщения от юзера
# chat_id - идентификатор юзреа
# chat_message - его сообщение
# additional_data - какие-то дополнительные данные
def on_message(self, chat_id, chat_message, additional_data):
"""
При получении собщения, вызываем process, с аргументами - id usera, его ввод, и любые дополнительные данные
Фреймворк сам загрузит контекст юзера, или создаст новый, определит состояние пользователя,
вызовет правильный обработчик и сохранит контекст обратно в бд.
По скольку программист сам описывает что будет возвращаться при обработке сообщений,
мы рассчитываем что из process мы получили ответное сообщение и посылаем его.
Если же поймали InvalidCommandException, считаем что юзер отправил "мусор" и игнорируем.
"""
try:
result = process(chat_id, chat_message, additional_data)
except InvalidCommandException:
return
self.send_messages(chat_id, result)
Данный пример показывает самый простой способ использования фреймворка, и его возможности этим далеко не ограничиваются. Объявление handler-ов, создание конфигурации, состояний и т.д. будет описано далее.
Комманды - объекты, представляющие собой намерения пользователя.
Состоят из название, совпадающего с названием объекта - Command.Start - и триггеров, то есть разных вводов юзера, которые относятся к данной команде - для комманды start это может быть ['/start', 'start']
.
Можно использовать любоей объект, поддерживающий сравнение, не только строки.
Иногда надо получить доступ к триггерам команды, например чтоб в боте создать кнопку для пользователя. Для этого можно воспользоваться специальным полем .triggers
, которое возвращает объект со следующими методами:
Command.Start.triggers.any
- любой триггер (случайный выбор)Command.Start.triggers.all
- все триггеры (список)Command.Start.triggers.first
- первый триггерCommand.Start.triggers.last
- последний триггер
Объект Commands
заполняется коммандами из конфига
В общем случае, для комманд он выглядит следующим образом:
COMMANDS = {
'Echo': None,
'End': '/end',
'Start': ['/start', 'start'],
}
Ключи - названия комманд, значения - триггеры. None можно использовать для "сервисных" комманд для особых состояний, про это будет сказано далее.
Для комманд необходимо регистрировать обработчики - @handler
.
Это делается с помощью декоратора handler.
Пример:
from spockesman import Command, handler, ABCResult
class Message(ABCResult):
def __init__(self, text):
self.text = text
@handler(Command.Start)
def start_handler(context, user_input, *args, **kwargs):
return Message('Привет!')
Разберем данный пример.
Из обработчиков можно возвращать только объекты, явялющиеся подклассами ABCResult
, состояние или его название в виде строки, либо списки из вышеперечисленного.
Тут мы объявили класс Message
как результат, который мы можем вернуть из handler.
Затем мы использовали декоратор @handler(Command.Start)
, чтобы указать фреймворку, что обработчиком для комманды Start
будет функция start_handler
(название не важно).
Все обработчики должны придерживаться одного интерфейса -
(контекста, ввод юзера, доп. *args, доп. *kwargs) -> ABCResult, str, State, либо любой iterable из этих элементов
Если вернуть из обработчика генератор, то он будет приведен к tuple во время обработки, поэтому не стоит ожидать генератор на "принимающей стороне".
Если вернуть из обработчика состояние или его название, или если оно будет встречено в списке результатов, пользователь будет переведен в это состояние, и из него будет взят default (см. секцию про состояния)
Контекст - объект с информацией о пользователе, которая нужна системе, и позволяющий хранить дополнительную информацию, которая нужна разработчику.
Простейший контекст состоит из user_id
- идентификатор юзера и state
- текущее состояние юзера.
В контексте можно сохранять дополнительные данные (только примитивы):
from spockesman import Context
context = Context('user_id')
context.store('key', 'value')
context['key'] == 'value' # True
context.pop('key') == 'value' # removes 'key', returns 'value'
Для хранения контекстов используется единый механизм, встроенный в фреймворк - программист может выбрать между redis и sqlite
(или написать свой бэкенд для хранения, для примера можно взять spockesman.context.backend.redis_backend
), и описать его конфигурацию в питоновском модуле (примеры config.py есть в tests и в example).
Разработчику не надо самому заботиться о хранении контекста.
В общем виде, конфигурация выглядит так:
# config.py
CONTEXT_BACKEND = {
'Name': 'example',
'Type': 'sqlite_backend',
}
При такой конфигурации, для хранения контекстов будет использоваться sqlite база с именем example. При использовании redis параметров для настройки больше, их можно найти в tests/config.py
Интерфейсы, описываемые в этой секции, могут меняться и находятся в разработке
От Context
можно наследоваться, если, например, хочется иметь дополнительные методы или поля.
Данные, сохраненные с помощью .store
хранятся во внутреннем словаре, и будут сохранены в бд в любом случае, поля же, по умолчанию, сохраняются только публичные.
Плюс использования подклассов и новых полей в том, что для хранения полей используется pickle
, а для того что сохранено через .store
- json
,
поэтому в дополнительных полях контекста можно хранить любые данные, а не только примитивы.
Однако pickle не безопасен, поэтому контроль за безопасностью хранимых и загружаемых данных возлагается на разработчика - для этого и существует разделение способов хранения.
Пример:
from spockesman import Context
class CustomContext(Context):
def __init__(self, new_field, *args, **kwargs, ):
super().__init__(*args, **kwargs)
self.new_field = new_field
Поле new_field
"переживет" сохранение/загрузку контекста.
Если вы хотите хранить дополнительные поля, или выполнять с ними какие либо действия перед загрузкой/сохранением,
вы можете переопределить методы prepare_additional_fields
и install_additional_fields
- первый вызывается перед сохранением для получения объекта для хранения в базе, второй - для загрузки данных, он получает как аргумент объект, возвращенный первым методом.
То есть - context.prepare_additional_fields() -> return object -> pass to pickle -> save to database -> load and unpickle -> pass object to context -> context.install_additional_fields(object)
.
По умолчанию, prepare_additional_fields
возвращает словарь со значениями публичных полей класса,
а install_additional_fields
добавляет их к новому инстансу.
Состояния - подклассы базового класса State
. Они представляют собой узлы в графе состояний.
В них содержится логика работы данного конкретного состояния.
Например, тип состояния CyclicState
будет передавать ввод юзера в один и тот же handler, если не найдет handler-ов с соответстующими триггерами. В какой именно handler будет передаваться ввод указывается в поле класса cycle
.
Определить самое просто состояние можно унаследовавшись от State
и указав нужные значения полей класса:
from spockesman import State
class MainState(State):
# default, который будет возвращен юзеру, когда он приходит в состояни "без инпута"
# т.е. если пользователь новый, либо его "пропушили" в состояние из handler-а (см. выше)
default = Message('Добро пожаловать!')
# Commands - словарь, ставящий в соответствие коммандам от пользователя состояние,
# в которое он должен будет перейти
commands = {
Commands.Start: AnotherState,
}
Самая интересная часть тут это commands = {...}
- таким образом мы описываем граф состояний, где комманды и их обработчики это ребра графа, а состояния - вершины.
Комманды сами по себе не являются ребрами, они нужны лишь для привязки функции-обработчиков.
Полное описание ребра состоит из исходной вершины (класса в котором мы описываем commands, тут это MainState
),
комманды и приязанного к ней обработчика (тут это Command.Start
) и конечной вершины (тут - AnotherState
)
Таким образом сейчас у нас получается вершина MainState
, с 1 ребром, связывающим ее с AnotherState
.
Конкретные состояния для юзера создаются путем создания инстанса состояния с контекстом пользователя -
from spockesman import Context, State
user_context: Context # созданный ранее контекст пользователя
user_state = State(user_context)
Теоретически, "вводом пользователя" и триггером для handler-а может быть почти любой объект, но пока что остановимся на тексте. Для обработки ввода пользователя, инстанс состояния надо вызвать -
result = user_state(user_input, *additional_args, **additional_kwargs)
result
будет каким-либо подклассом ABCResult
или списком из них.
Обработка проходит следующим образом:
- State среди всех комманд ищет комманду, среди триггеров которой был бы данный user_input. Если не находит, выбрасывается
InvalidCommandException
. Само по себе это может не быть "ошибкой в программе", часты use case является необходимость игнорировать "мусор" от пользователя, тогда можно просто ловитьInvalidCommandException
. - Если комманда найдена, то проверяется, является ли комманда глобальной (доступной из любого состояния). Если да, то вызывается соответствующий обработчик и возвращается результат.
- Если комманда не глобальная, то проверяем, доступа ли эта комманда в данном состоянии. Если нет, выбрасывается
InvalidCommandException
- Если комманда найдена, ищем зарегистрированный для нее handler. Если его нет, выбрасывается
NoHandlerException
. - Если комманда найдена, доступна в текущем состоянии, и существует handler для нее, то вызывается handler c аргументами
user_input, context, *args, **kwargs
, пользователь переходит в состояние, привязанное к обрабатываемой команде в текущем состоянии (т.е. происходит переход по графу состояний), и возвращается результат работы handler
По умолчанию доступны следующие базовые классы состояний:
State
, самое простое состояние, принцип его работы описан вышеCyclicState
, уже упомянутый выше - будет передавать ввод юзера в один и тот же handler, если не найдет handler-ов с соответстующими триггерами. В какой именно handler будет передаваться ввод указывается в поле классаcycle
. То есть, используя описание работы описанное выше, можно сказать что в пункте 3 вместа выбросаInvalidCommandException
, будет использована комманда, указанная вstate.cycle
, и юзер останется в текущем состоянииTransientState
- аналогичноCyclicState
, но имеет полеtransition
- словарь с двумя полями:Command
- комманда по умолчанию аналогичноcycle
изCyclicState
, иState
- состояние в которое перейдет пользователь
Кроме того, для корректной работы, необходимо объявить исходное состояние для пользоателя. Это можно сделать с помощью декоратора @initial
:
from spockesman import State, initial
@initial
class SomeState(State):
...
- More tests: context backend, state processing, tricky handlers
- Remove global variables! Make state machine a class, instead of collection of
COMMANDS
andSTATES
-
- Generate config file from loaded state machine
- Move to pytest-style tests?