Введение 

Как вам идея создавать веб-приложения полностью на Python? Звучит заманчиво! Разработка веб-приложения требует владения навыками как фронтенда, так и бэкенда. В их число входят HTML, CSS, JavaScript, разные фреймворки, а также Python и другие серверные языки бэкенда. Как видно, предполагается большой объем работы! 

А если бы для создания веб-приложения использовался только Python, теоретически даже один файл, то скорость разработки возросла бы в разы. В настоящее время существуют несколько библиотек, которые пытаются реализовать эту идею на практике. Самая популярная из них, судя по количеству звезд на GitHub,  —  это Streamlit. На момент написания статьи она была отмечена 24,9 тыс. звезд. 

Несмотря на невероятную лаконичность Streamlit и способность создавать веб-приложения в несколько строк кода, у этой библиотеки есть и недостатки. Один из них  —  ограниченные возможности для создания пользовательских интерфейсов. Кроме того, следует отметить ее довольно странный механизм, при котором все перезапускается при каждом изменении состояния. 

Не имею ничего против Streamlit. С удовольствием с ней работаю, если она способствует решению поставленных задач. Но вот для создания приложений со сложными интерфейсами и/или вложенными состояниями она не подходит. 

Таким образом, для воплощения задуманного нужен новый, более настраиваемый фреймворк, который органично бы объединял гибкость фронтенд-фреймворков и Python. 

Solara

Не так давно был представлен фреймворк Solara, предназначенный для создания веб-приложений на чистом Python. В его документации содержатся интересные улучшения Streamlit. К ним относятся вложенные переиспользуемые компоненты с собственными состояниями, не требующие повторного выполнения без необходимости, а также простая интеграция с Jupyter Notebook.

Особенно впечатляет тот факт, что официальный сайт Solara также создан с помощью фреймворка Solara, чего не скажешь о Streamlit. Предлагаем ссылки на официальный репозиторий GitHub и сайт Solara

В теории все звучит прекрасно. Но как выглядит код? Так ли он хорош на практике? Помогает ли он эффективно создавать нужные приложения? 

В следующих разделах статьи мы протестируем данный фреймворк. Для этого создадим что-нибудь одновременно простое, но при этом достаточно сложное, чтобы проверить его возможности, а именно приложение-планировщик задач todo app. Я уже проводил подобный эксперимент с Shiny для Python, теперь настала очередь Solara. Изображение итогового результата: 

В конце статьи продемонстрируем работу получившегося приложения. За дело! 

Написание кода в Solara

Перед началом работы следует отметить пару моментов. Во-первых, фреймворк является совершенно новым, и сложно предсказать возможные изменения по мере его развития. Кроме того, я не претендую на звание эксперта Solara. 

Начнем с определения глобальных переменных: 

import solara

text_input = solara.reactive("")
todos = solara.reactive([
{ "text": solara.reactive("Learn Solara"), "done": solara.reactive(False) },
{ "text": solara.reactive("Build a Solara app"), "done": solara.reactive(False) }
])

Как видно, эта операция выполняется с помощью solara.reactive(...). Для строковых переменных просто добавляется строка в качестве начального значения. Для приложения-планировщика срабатывает добавление вложенных элементов словаря, также определенных с помощью solara.reactive

Page()

Затем определяем основной компонент с именем Page:

@solara.component
def Page():
# добавление css
solara.Style("""
.add-button {
margin-right: 10px;
}
""")

# центрирование карты
with solara.Column(align="center"):
with solara.Card(title="Todo App"):
for todo in todos.value:
Todo(todo)
if len(todos.value) == 0:
solara.Text("No todos yet.")

solara.InputText(label="Add a todo", value=text_input),
solara.Button("Add", on_click=on_add_todo, classes=["primary", "add-button"]),
solara.Button("Remove finished tasks", classes=["secondary"], on_click=clear_finished_todos),

Page()

Рассмотрим код шаг за шагом. Компонент определяется посредством декоратора @solara.component. В данном случае это основной компонент, содержащий весь интерфейс. Сначала в компоненте вызывается solara.Style(...) для добавления стилей CSS к одной из кнопок (обратите внимание на атрибуты classes=[..., “add-button”] для этой кнопки). Не стоит волноваться, если вы не знакомы с CSS. Данная операция относится к разряду нетипичных, о чем и предупреждается в документации.

Примечание. Это операция считается функциональностью повышенного уровня сложности, требующей осторожного подхода. 

Я намеренно ее использовал, чтобы протестировать способность фреймворка задействовать такие распространенные фронтенд-инструменты, как CSS.

Далее создаем solara.Column для центрирования компонентов внутри и solara.Card для создания стилизованного контейнера: 

with solara.Column(align="center"):
with solara.Card(title="Todo App"):
...

На следующем этапе перебираем элементы в глобальном состоянии todos, которое было определено ранее, и передаем каждое состояние в списке отдельному компоненту Todo:

for todo in todos.value:
Todo(todo)
if len(todos.value) == 0:
solara.Text("No todos yet.")

Код для компонента Todo рассмотрим позже. 

Наконец, код предусматривает текстовое поле для ввода задач и 2 кнопки: одна для добавления новых задач, а другая для удаления выполненных.

solara.InputText(label="Add a todo", value=text_input),
solara.Button("Add", on_click=on_add_todo, classes=["primary", "add-button"]),
solara.Button("Remove finished tasks", classes=["secondary"], on_click=clear_finished_todos),

Для текстового поля значение устанавливается в одно из глобальных состояний, которое автоматически обновляется при потери элементом фокуса (или при каждом нажатии клавиши совместно с continuous_update=True).

Обе кнопки располагают обратными вызовами on_click для обработки логики при нажатии. Рассмотрим код: 

def on_add_todo():
todos.set(todos.value + [{
"text": solara.Reactive(text_input.value),
"done": solara.Reactive(False)
}])
text_input.set("")

def clear_finished_todos():
todos.set([todo for todo in todos.value if not todo["done"].value])

Первый метод on_add_todo выполняет конкатенацию старых элементов todo с новым элементом, который принимает значение внутри текстового поля, а затем его очищает. Обратите внимание, что переменные todos и text_input являются объектами Reactive (определенными ранее с помощью solara.Reactive(...)), которые обрабатывают логику, необходимую для состояний. Таким образом, для доступа к их текущим фактическим значениям требуется задействовать метод доступа .value

Второй метод clear_finished_todos перебирает элементы todo и удаляет те, которые выполнены, т.е. “done”. Поскольку элемент словаря done также является объектом Reactive, доступ к его значению осуществляется посредством .value.

Todo()

Последний фрагмент мозаики  —  функция Todo, переиспользуемый компонент, с помощью которого отображается каждый элемент todo. Ниже представлен код: 

@solara.component
def Todo(todo):
# определение локального состояния, только для данного компонента
editing, set_editing = solara.use_state(False)

# размер 0 будет занимать минимум места
with solara.Columns([1, 0]):
# установка фонового цвета в зависимости от состояния done
color = "#d6ffd6" if todo["done"].value else "initial"
# css для придания привлекательного внешнего вида
with solara.Column(style=f"padding: 1em; width: 400px; background-color: {color};"):
# если редактирование (editing) является true, элемент редактируется
if editing:
solara.InputText(label="Edit todo", value=todo["text"])
else:
solara.Checkbox(label=todo["text"].value, value=todo["done"])

# кнопки для редактирования/сохранения и удаления
solara.Column(children=[
(
solara.IconButton(icon_name="save", on_click=lambda: set_editing(False))
if editing
else
solara.IconButton(icon_name="edit", on_click=lambda: set_editing(True))
),
solara.IconButton(icon_name="delete", on_click=lambda: todos.set([t for t in todos.value if t != todo]))
])

Сначала вызывается solara.use_state(<initial value>) для определения локального состояния внутри компонента. Если вы работали с функциональными компонентами в ReactJS, то эта процедура вам знакома. 

Далее определяем 2 столбца:

with solara.Columns([1, 0]):
...

Один из них должен растягиваться (текст), а другой  —  быть минимального размера (кнопки). 

Текст отображается следующим образом:

# установка фонового цвета в зависимости от состояния done
color = "#d6ffd6" if todo["done"].value else "initial"
# css для придания привлекательного внешнего вида
with solara.Column(style=f"padding: 1em; width: 400px; background-color: {color};"):
# если редактирование (editing) является true, элемент редактируется
if editing:
solara.InputText(label="Edit todo", value=todo["text"])
else:
solara.Checkbox(label=todo["text"].value, value=todo["done"])

Добавляем стиль CSS background-color в зависимости от состояния done вместе с фиксированными стилями CSS. Затем с учетом состояния editing содержимое подгоняется либо под флажок (checkbox), либо поле для ввода текста. Флажок, по аналогии с текстовым полем, обновляет value объекта Reactive при нажатии.

И наконец, обзаводимся кнопками в виде иконок, расположенными вертикально: 

# кнопки для редактирования/сохранения и удаления
solara.Column(children=[
(
solara.IconButton(icon_name="save", on_click=lambda: set_editing(False))
if editing
else
solara.IconButton(icon_name="edit", on_click=lambda: set_editing(True))
),
solara.IconButton(icon_name="delete", on_click=lambda: todos.set([t for t in todos.value if t != todo]))
])

При редактировании отображается кнопка save, в противном случае  —  кнопка edit. Кроме того, имеется кнопка delete для удаления текущих задач. 

Готово! Посмотрим, что получилось: 

Обратите внимание, что при нажатии на флажок в компоненте Todo, обновляется только компонент Todo, а не компонент Page. Такой подход позволяет создавать более эффективные программы. 

Полный вариант кода:

import solara

text_input = solara.reactive("")
todos = solara.reactive([
{ "text": solara.reactive("Learn Solara"), "done": solara.reactive(False) },
{ "text": solara.reactive("Build a Solara app"), "done": solara.reactive(False) }
])

def on_add_todo():
todos.set(todos.value + [{
"text": solara.Reactive(text_input.value),
"done": solara.Reactive(False)
}])
text_input.set("")

def clear_finished_todos():
todos.set([todo for todo in todos.value if not todo["done"].value])

@solara.component
def Todo(todo):
# определение локального состояния только для данного компонента
editing, set_editing = solara.use_state(False)

# размер 0 будет занимать минимум места
with solara.Columns([1, 0]):
# установка фонового цвета в зависимости от состояния done
color = "#d6ffd6" if todo["done"].value else "initial"
# css для придания привлекательного внешнего вида
with solara.Column(style=f"padding: 1em; width: 400px; background-color: {color};"):
# если редактирование является true, элемент редактируется
if editing:
solara.InputText(label="Edit todo", value=todo["text"])
else:
solara.Checkbox(label=todo["text"].value, value=todo["done"])

# кнопки для редактирования/сохранения и удаления
solara.Column(children=[
(
solara.IconButton(icon_name="save", on_click=lambda: set_editing(False))
if editing
else
solara.IconButton(icon_name="edit", on_click=lambda: set_editing(True))
),
solara.IconButton(icon_name="delete", on_click=lambda: todos.set([t for t in todos.value if t != todo]))
])

@solara.component
def Page():
solara.Style("""
.add-button {
margin-right: 10px;
}
""")

# to центрирование карты
with solara.Column(align="center"):
with solara.Card(title="Todo App"):
for todo in todos.value:
Todo(todo)
if len(todos.value) == 0:
solara.Text("No todos yet.")

solara.InputText(label="Add a todo", value=text_input),
solara.Button("Add", on_click=on_add_todo, classes=["primary", "add-button"]),
solara.Button("Remove finished tasks", classes=["secondary"], on_click=clear_finished_todos),

Page()

Запуск приложения Solara в Jupyter Notebook

Этот код можно легко запустить в Jupyter Notebook. Для этого просто вставляем код в ячейку и запускаем его: 

Заключение 

Тестирование фреймворка Solara позволило обнаружить в нем ряд преимуществ, отсутствующих в Streamlit. Среди них  —  переиспользуемые компоненты, усложненная функциональность с локальными и глобальными состояниями, а также более широкие возможности настройки, например с CSS. 

Однако выявились и недочеты. Во-первых, хотелось бы задействовать базовые HTML-компоненты. На данный момент можно вызвать solara.HTML(...), но только для изолированного рендеринга HTML с единственной возможностью чтения. Обернуть в них другие компоненты уже не получится. Помимо этого, отсутствуют и другие базовые компоненты. Например, нет многострочных текстовых полей, хотя вероятно их появление в ближайшем будущем. 

В целом, фреймворк Solara обладает большим потенциалом, и в моей практике он придет на смену Streamlit. Но в настоящее время он не готов заменить ReactJS на стороне клиента и Python на стороне сервера в ситуациях, требующих исключительной гибкости. 

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Jacob Ferus: Web Apps in Python with Solara — A Streamlit Killer?

Предыдущая статьяПочему ИИ не лишит работы программистов
Следующая статьяЛогирование  —  корень всех проблем отладки