Fixed force parameter. Added new worker ipc, some pydantic response and requests schemas and server api description.

master
Иванов Денис 3 years ago
parent 91690f73e2
commit c0a552a1cf

@ -42,11 +42,11 @@ class Command:
def __init__(self, command_id: str = '', def __init__(self, command_id: str = '',
category: str = '', category: str = '',
title: str = '', title: str = '',
command: str = '',
script: Union[Callable, Script, None] = None, script: Union[Callable, Script, None] = None,
args: Union[tuple, list] = tuple(), args: Union[tuple, list] = tuple(),
parameters: Union[List[BaseParameter], None] = None, parameters: Union[List[BaseParameter], None] = None,
namespace: Union[str, NamespaceNode, None] = None, namespace: Union[str, NamespaceNode, None] = None,
command: str = '',
gui: bool = False, gui: bool = False,
icon: Union[str, List[str]] = '', icon: Union[str, List[str]] = '',
setvars: dict = {}, setvars: dict = {},
@ -176,7 +176,7 @@ class CommandRunner:
def __init__(self, command: Command, def __init__(self, command: Command,
datavars: Union[Datavars, NamespaceNode], datavars: Union[Datavars, NamespaceNode],
output: IOModule): output: IOModule):
'''Класс инкапулирующий все данные о команде, а также необходимые для '''Класс инкапcулирующий все данные о команде, а также необходимые для
нее параметры, модуль ввода-вывода, переменные и прочее. Предназначен нее параметры, модуль ввода-вывода, переменные и прочее. Предназначен
для конфигурации команды и запуска ее в воркере.''' для конфигурации команды и запуска ее в воркере.'''
self._command = command self._command = command

@ -232,7 +232,7 @@ class Table(ParameterType):
def process_value(self, value: TableValue, parameter): def process_value(self, value: TableValue, parameter):
if isinstance(value, TableValue): if isinstance(value, TableValue):
# Если родитель не установлен -- значит параметр был только что # Если родитель не установлен -- значит параметр был только что
# инициализирован.его, и проводим полную проверку # инициализирован. Устанавливаем его, и проводим полную проверку
# инициализированных значений таблицы. # инициализированных значений таблицы.
if value._parent is None: if value._parent is None:
value._parent = parameter value._parent = parameter
@ -270,7 +270,7 @@ class Password(ParameterType):
class Description: class Description:
'''Класс, содержащий описание параметра и способ его отображения.''' '''Класс, содержащий описание параметра и способ его отображения.'''
# словарь со способами отображения параметра в графическом интерфейсе. # Словарь со способами отображения параметра в графическом интерфейсе.
representations = {} representations = {}
def __init__(self, short='', full='', usage=''): def __init__(self, short='', full='', usage=''):
@ -434,7 +434,7 @@ class BaseParameter:
else: else:
return None, None return None, None
def to_set(self, *variables): def set_to(self, *variables):
'''Метод для добавления переменных, которым будет установлено значение '''Метод для добавления переменных, которым будет установлено значение
параметра.''' параметра.'''
self._subscribers = variables self._subscribers = variables

@ -2,7 +2,7 @@
# #
import re import re
import inspect import inspect
from typing import Callable, Any, Union, List, Generator from typing import Callable, Any, Union, List, Generator, Optional
from calculate.templates.template_processor import DirectoryProcessor from calculate.templates.template_processor import DirectoryProcessor
from calculate.variables.datavars import ( from calculate.variables.datavars import (
DependenceAPI, DependenceAPI,
@ -46,18 +46,18 @@ class Script:
в себе. Создает экземпляры лаунчеров.''' в себе. Создает экземпляры лаунчеров.'''
def __init__(self, script_id: str, def __init__(self, script_id: str,
args: List[Any] = [], args: List[Any] = [],
success_message: Union[str, None] = None, success_message: Optional[str] = None,
failed_message: Union[str, None] = None, failed_message: Optional[str] = None,
interrupt_message: Union[str, None] = None): interrupt_message: Optional[str] = None):
self._id: str = script_id self._id: str = script_id
self.__items: List[Union['Task', 'Block', 'Handler', 'Run']] = None self.__items: List[Union['Task', 'Block', 'Handler', 'Run']] = None
self.__tasks_is_set: bool = False self.__tasks_is_set: bool = False
self.__args: list = args self.__args: list = args
self.__success_message: Union[str, None] = success_message self.__success_message: Optional[str] = success_message
self.__failed_message: Union[str, None] = failed_message self.__failed_message: Optional[str] = failed_message
self.__interrupt_message: Union[str, None] = interrupt_message self.__interrupt_message: Optional[str] = interrupt_message
@property @property
def id(self) -> str: def id(self) -> str:
@ -198,7 +198,7 @@ class ScriptLauncher:
return scripts return scripts
def __call__(self, *args): def __call__(self, *args) -> None:
'''Метод для запуска скрипта с аргументами.''' '''Метод для запуска скрипта с аргументами.'''
if len(args) < len(self._script.args): if len(args) < len(self._script.args):
raise ScriptError( raise ScriptError(
@ -219,7 +219,7 @@ class ScriptLauncher:
def _get_args_values(self, args: List[Any], def _get_args_values(self, args: List[Any],
datavars: Union[Datavars, NamespaceNode], datavars: Union[Datavars, NamespaceNode],
namespace: NamespaceNode): namespace: NamespaceNode) -> list:
'''Метод для получения значений аргументов.''' '''Метод для получения значений аргументов.'''
args_values = [] args_values = []
for argument in args: for argument in args:
@ -235,7 +235,7 @@ class ScriptLauncher:
return args_values return args_values
class RunTemplate: class RunTemplates:
'''Класс запускателя наложения шаблонов.''' '''Класс запускателя наложения шаблонов.'''
def __init__(self, id: str = '', def __init__(self, id: str = '',
action: str = '', action: str = '',

@ -0,0 +1,830 @@
Вопросы
-------
1. >> Что является ресурсом?
* commands -- информация о командах, их список, группы, описания и тд.
* workers(?) -- воркеры, исполняющие команды. Сомнительно.
Возможно есть смысл скрыть реализацию, т.е. воркеров, под некоторыми
другими сущностями. Например, execution и configuration.
workers -- мб оставить как дополнительный низкоуровневый интерфейс.
<< Ресурсы: commands, configuration, execution, workers (low_level)
2. >> Что делать с демонами-воркерами, обнаруженными при инциализации сервера,
но не зарегистрированными в БД?
<< Убиваем их и удаляем PID-файлы.
3. >> Что делать с демонами-воркерами, для которых найдены PID-файлы, по PID
есть информация в БД, но WID из имени PID-файла не совпадает с WID в БД.
<< Убиваем воркера, удаляем PID-файл и запись в БД.
4. >> Стоит ли убрать разделение команд и их id?
<< Не стоит.
5. >> Нужна ли возможность сброса текущих параметров конфигурации?
<< Да.
Взаимодействие с воркером с точки зрения сервера
------------------------------------------------
1. Сервер получает от клиента запрос, POST /configs/{command_id}. Далее:
* Сервер создает объект воркера команды и запускает с его помощью
демона-рабочего, оставляя его в режиме ожидания конфигурационных данных.
* После создания объекта воркера в БД сохраняется основная информация
необходимая для воссоздания объекта воркера в новом процессе сервера. Такая
ситуация может возникнуть при переходе на новую версию сервера:
- int wid -- идентификатор воркера;
- str socket -- путь к сокету для чтения данных воркера;
- int daemon_pid -- PID демона-воркера для взаимодействия с ним.
* В ответ на запрос сервер получает список доступных ресурсов, включающих
в себя в том числе действия воркера, а также URI этих ресурсов и
доступные для этого URI http-методы. На данном этапе:
- configure (patch) -- изменить некоторые параметры конфигурации;
- cancel (delete) -- закончить конфигурацию без продолжения выполнения.
По сути удаляет данную конфигурацию как ресурс;
- execute (post) -- запустить выполнение команды с дефолтной
конфигурацией. По сути удаляет конфигурацию как ресурс и создает
новый ресурс -- выполнение.
2. Сервер получает от клиента запрос PATCH /configs/{WID}/parameters,
содержащий в теле конфигурационные данные. Далее:
* Формируется конфигурационное сообщение (1a), содержащее в себе список
объектов, описывающих один или несколько параметров команды, которое
затем отправляется воркеру.
* Oжидается ответ от воркера (1a) с информацией о результатах обработки
параметров, найденных ошибках и т.д, который пересылается клиенту.
* Конфигурационный цикл повторяется.
Возможно стоит добавить на стороне сервера таймаут при получении ответа от
воркера.
3. Сервер получает от клиента запрос POST /execs/{WID}, указывающий
закончить конфигурацию команды и начать выполнение.
Далее:
* Формируется сообщение воркеру (3a), сообщающее уже ему об окончании цикла
конфигурации.
* Если есть неисправленные ошибки, воркер отвечает на него сообщением (4b),
содержащим оповещение о том, что он заканчивает работу;
Если ошибок нет, то сообщением (4a), оповещающим об успешном запуске
скрипта команды.
* В случае, когда обработка параметров прошла с ошибками -- сразу после
выдачи сообщения об ошибках конфигурации, воркер отправляет сообщение
об окончании своей работы, содержащее список ошибок (9b) и (?) ждет
подтверждение от сервера (?).
4. Сервер получает от клиента запрос GET /execs/{WID}/output на
получение имеющихся выходных данных воркера. Далее:
* Cервер читает все, что есть на сокете воркера. Сообщения (5a, 5b или 6a)
на сокете разделены нулевым символом '\0'. Собирается список полных
сообщений на сокете, формируется сообщение, содержащее этот список,
которое отправляется клиенту.
5. Сервер получает от клиента запрос PATCH /execs/{WID}/input на передачу
воркеру ввода, который был запрошен последним в одном из сообщений ранее
переданных клиенту через сервер. Далее:
* Сервер формирует сообщение (7a) воркеру с данными ввода и отправляет его
воркеру. Если данных нет, что означает, что ввод был сброшен
пользователем, воркер отправляет серверу сообщение (7b) об некорректном
вводе, а метод input объекта ввода/вывода кидает в скрипте специальное
исключение.
Возможно, необходимо убеждаться в том, что данные успешно получены воркером.
Воркер должен ответить сообщением (8a) об успешном получении данных.
- Если данное сообщение не получено в течение некоторого времени
(таймаута) -- выполняем некоторые действия по диагностике и устранению
последствий ошибки, затем отправляем клиенту сообщение, сообщающее о том,
что воркер закончил работу, и содержащее последние сообщения, полученные
от воркера, если таковые имеются.
- Если сообщение об успешном вводе в воркер получено -- отправляем
соответствующее сообщение клиенту.
6. Работа воркера закончилась в результате успешного выполнения команды,
или было прервано клиентом командой stop, или по причине ошибок, возникших
при выполнении скриптов команды. Далее:
* Воркер отправляет на сервер одно из двух специальных сообщений,
оповещающих об окончании работы:
- Содержащее также информацию об ошибках (9b), если таковые
были, неотправленных по каким-то причинам сообщениях (?) или о факте
выполнения команды stop.
- Содержащие только объявление об окончании выполнения команды, то есть
состояния run.
В зависимости от того, возможно ли чтение из сокета, когда процесс воркера
уже закрыт сервер будет или не будет отправлять сообщение разрешающее
воркеру прекратить работу, но клиент его видеть не должен. По факту
получения конечного сообщения и, возможно, данных вместе с ним, клиенту
будет присылаться специальное сообщение.
Контекст воркера
----------------
worker_state -- состояние воркера;
socket_context -- контекст чтения сокета воркера;
worker_output -- вывод воркера перед его выключением сервера.
Что делать
----------
[*] Проверить возможность чтения сервером данных из сокета уже закрытого
воркера. Если такой возможности нет, предусмотреть обработку завершения и
успешной, и некорректной работы воркера с передачей всех необходимых
сообщений. Сделать это внутри воркера.
Итог: чтение из сокета убитого демона возможно.
[ ] Добавить в воркер цикл конфигурации или его тестовый вариант;
[*] Убрать на стороне интерфейса клиента лишний шаг создания объекта воркера;
[*] Убрать self._runner_process и self._pid_file, они в таком случае не нужны;
[*] PID-файл демона удаляем сразу после чтения;
[ ] Реализовать описанный ниже протокол взаимодействия сервера с воркером;
[ ] Добавить в метод kill воркера чтение всего содержимого сокета перед его
удалением.
[ ] Разработать HAL-формат сообщений об ошибках вместо HTTPException.
[*] Сделать метод read воркера асинхронным.
[ ] На случай если воркер убит через SIGKILL -- поскольку обработку
такого завершения работы воркера в самом воркере организовать невозможно,
добавить дополнительную проверку is_alive воркера и предусмотреть обработку
этой ситуации.
[*] Добавить главный блок try-catch-finally в воркер. В раздел finally добавить
отправку сообщения об окончании работы воркера. В зависимости от
возможности читать данный из сокета законченного процесса воркера, ждать
или не ждать ответ сервера по поводу окончания работы воркера;
[*] Добавить предварительно деление воркеров по состояниям, вероятно добавить
WorkersManager;
[ ] Добавить состояния воркера:
- config -- воркер в состоянии конфигурации, Т.е. ждёт значения
параметров или собщение об окончании конфигурации;
- exec -- воркер в процессе выполнения команды;
- input -- воркер в процессе ожидания ввода пользователя;
- finish -- воркер в состоянии завершения некоторого состояния.
[ ] Реализовать предварительно REST API в соответствии с наработками;
[*] Решить, какое значение должны иметь заголовки Content-Type и Accept:
- application/json;
- application/hal+json;
- application/vnd.cphl+json;
- application/vnd.api+json.
Подумать над версионированием API;
[ ] Добавить в ответы сервера обозначения о кэшируемости и некэшируемости,
проработать кэширование в целом, определить, что должно кэшироваться, а что
нет;
[ ] Проработать механизм остановки взаимодействия сервера с клиентом и
возвращения к нему после переподключения клиента, перезагрузки сервера, а
также при этих обоих событиях;
[ ] Добавить проверку во время инициализации сервера наличия PID-файлов
демонов-воркеров:
- Если такие есть -- проверить, что это за процессы;
- Если такие процессы не существуют или не являются воркерами -- удалить
эти PID-файлы;
- Если процессы существуют, являются воркерами и есть информация о них
в БД -- проверяем соответствие PID и WID.
-- Если все совпадает -- удаляем PID-файлы и далее создаем объекты
воркеров по информации в БД;
-- [Q#3] Если есть несовпадения -- убиваем воркер, PID-файл и
записи в БД;
- [Q#2] Если процессы существуют, являются воркерами, но в БД о них нет
информации -- наверное, удаляем PID-файлы и пытаемся убить этих
воркеров с помощью их PID.
Интерфейс сервера
-----------------
REST-интерфейс (вроде). Представляем состояния воркеров в виде ресурсов или
объектов. В соответствии с HATEAOS присылаем URI на ресурсы и действия воркера,
которые доступны в тот или иной момент. Для этого применяем формат
json-сообщений напоминающий HAL или CPHL (не очень распространенный формат).
Различие от HAL в наличии атрибута "methods" в "_links".
* root -- корневой ресурс, являющийся точкой входа для всего API. Возможно,
стоит создать алиас /index или заменить на него.
- GET / -- получить доступные корневые ресурсы;
* request:
null
* response:
Media-Type: application/hal+json
Cache-Control: public
{
"data": {
// Some information about server.
},
"_links": {
"commands": {
"href": "/commands/"
},
"workers": {
"href": "/workers/"
}
}
}
{
"meta": {
// Server info
},
"links": {
}
}
* сommands -- совокупность данных о командах, доступных для запуска на
сервере.
- GET /commands/ -- получить список команд;
* request:
Media-Type: application/json
{
"gui": {true/false}
}
* response:
Media-Type: application/hal+json
Cache-Control: private
{
"{command_id}": {
"data": {
// Some command info.
},
"_links": {
"self": {
"href": "/commands/{CID}"
},
"parameters": {
"href": "/commands/{CID}/parameters"
},
"cofigure": {
"href": "/configs/{CID}"
}
}
},
...
}
- GET /commands/{command}/ -- получить информацию об указанной команде, a
также ее параметры. Запрос используемый
консольным клиентом;
* request:
null
* response:
Media-Type: application/hal+json
Cache-Control: private
{
"data": {
// Some command info.
},
"_links": {
"self": {
"href": "/commands/{CID}"
},
"parameters": {
"href": "/commands/{CID}/parameters"
}
"cofigure": {
"href": "/configuration/{CID}"
}
},
"_embedded": {
"parameters": {
"data": [
{
"group_id": "{group_id}",
"parameters": [...]
},
...
],
"_links":
"self": {
"href": "/commands/{CID}/parameters"
}
}
}
}
- GET /commands/{CID}/parameters -- получить параметры указанной команды;
* request:
null
* response:
{
"data": [
{
"group_id": "{group_id}",
"parameters": [...]
},
...
],
"_links": {
"self": {
"href": "/commands/{CID}/parameters"
}
}
}
* configs -- совокупность объектов воркеров, находящихся в состоянии
конфигурации.
- POST /configs/{command} -- создать конфигурацию;
201 -- конфигурация уже создана;
404 -- команды с указанным id нет;
400 -- неправильный формат запроса.
* request:
null
* response:
{
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"execute": {
"href": "/executions/{WID}"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
- PATCH /configs/{WID}/parameters -- изменить конфигурацию
указанными в теле значениями;
404 -- конфигурации с указанным
WID нет;
400 -- неправильный формат
запроса.
* request:
[
{"id": "{param_id}", "value": "{param_value}"},
...
]
* response:
{
"data": {
"{param_id}": "{error_message}",
...
},
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
OR
{
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"execute": {
"href": "/executions/{WID}"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
- PUT /configs/{WID}/parameters -- заменить конфигурацию с
использованием указанных в теле
запроса значений. Пустой набор
значений позволяет сбросить
конфигурацию;
* request:
[
{"id": "{param_id}", "value": "{param_value}"},
...
]
* response:
{
"data": {
"{param_id}": "{error_message}",
...
},
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
OR
{
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"execute": {
"href": "/executions/{WID}"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
- DELETE /configs/{WID} -- закончить конфигурации без выполнения
команды.
* request:
null
* response:
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
...
]
* executions -- совокупность объектов воркеров, находящихся в состоянии
исполнения.
- POST /executions/{WID} -- создать новое исполнение из конфигурации;
201 -- исполнение создано;
409 -- исполнение с этим WID уже есть;
400 -- неправильный формат запроса.
404 -- конфигурация не найдена.
* request:
null
* response:
- Response Code: 201
{
"_links": {
"output": {
"href": "/executions/{WID}/output"
},
"stop": {
"href": "/executions/{WID}"
}
}
}
- DELETE /executions/{WID} -- удалить исполнение, происходит путем
отправки в скрипт SIGINT;
* request:
null
* response (возвращает список непереданных сообщений, если они есть):
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
...
]
- GET /executions/{WID}/output -- получить список имеющихся сообщений от
команды;
* request:
null
* response (необходимость остановить выполнение определяется по
Response Code):
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
{
"data": {
"type": "input",
"text": "{input_message}"
"source": "{source_script}"
},
"_links": {
"input": {
"href": "/executions/{WID}/input",
}
}
}
]
- PATCH /executions/{WID}/input -- отправить в воркер некоторый ввод;
* request (если null, значит ввод прерывается):
{
"data": {input_data/null}
}
* response:
- Response Code: 200
null
- Response Code: 404
{
"error": "Execution {WID} is not found"
}
- Response Code: 400
{
"error": "Input message is not correct"
}
* workers -- вся совокупность воркеров.
- GET /workers/ -- получить список демонов-воркеров и информацию о них;
* request:
null
* response:
{
"{WID}": {
"data": {
"socket": "{socket_path}",
"command": "{worker_command}",
"pid": "{daemon_pid}",
}
"_links": {
"kill": {
"href": "/workers/{WID}",
"methods": ["delete"]
}
}
},
...
}
- DELETE /workers/{WID} -- отправить сигнал SIGKILL демону-воркеру;
* request:
null
* response (сообщения полученные от убитого воркера):
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
...
]
Протокол взаимодействия воркера и сервера
-----------------------------------------
Все сообщения представляют собой json-структуры, включающие в себя 3 поля:
- state -- состояние воркера:
* Если сообщение пришло воркеру от сервера, тогда это поле указывает
воркеру, какое состояние он должен принять;
* Если сообщение пришло серверу от воркера, тогда это поле указывает на
текущее состояние воркера.
- status -- статус текущего состояния:
0 -- состояние корректно;
1 -- состояние некорректно.
- data -- некоторые данные, структура которых может быть любой.
1. Данные конфигурации от клиента.
a. Продолжить конфигурацию указанными значениями:
{
"state": "config",
"status": 0,
"data": [
{"id": "{param_id}", "value": "{parameter_value}"},
...
]
}
b. Сбросить конфигурацию и продолжить указанными значениями:
{
"state": "config",
"status": 1,
"data": [
{"id": "{param_id}", "value": "{parameter_value}"},
...
]
}
2. Ответ воркера по данным конфигурации.
a. Все параметры конфигурации корректны:
{
"state": "config",
"status": 0,
"data": null
}
b. Некоторые параметры конфигурации некорректны:
{
"state": "config",
"status": 1,
"data": {
"{param_id}": "{error_message}",
...
}
}
3. Запрос на окончание конфигурации от клиента.
a. Закончить конфигурацию, начать выполнение команды:
{
"state": "finish",
"status": 0,
"data": null
}
b. Закончить конфигурацию и работу воркера:
{
"state": "finish",
"status": 1,
"data": null
}
4. Ответ воркера на запрос об окончании конфигурации:
a. Закончить конфигурацию, начать выполнение команды:
{
"state": "finish",
"status": 0,
"data": null
}
b. Закончить конфигурацию и работу воркера:
{
"state": "finish",
"status": 1,
"data": {
"{param_id}": "{error_message}",
...
}
}
5. Сообщение с выводом воркера.
a. Сообщение из скрипта при его "штатной" работe:
{
"state": "exec",
"status": 0,
"data": {
"type": "message",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}"
}
}
b. Сообщение об ошибке приводящей к завершению выполнения скрипта и
команды:
{
"state": "exec",
"status": 1,
"data": {
"error": "{error_message}",
"source": "{source_script}"
}
}
6. Сообщение с запросом на ввод.
a. Состояние "input" -- воркер в состоянии ожидания ввода:
{
"state": "input",
"status": 0,
"data": {
"text": "{message_text}",
"source": "{source_script}"
}
}
7. Сообщение от клиента с данными.
a. Ввод успешно выполнен и передается:
{
"state": "input",
"status": 0,
"data": "{client_data}"
}
b. Ввод данных прерван, данных не будет:
{
"state": "input",
"status": 1,
"data": null
}
8. Сообщение от воркера о получении ввода.
a. Ввод успешно получен, о чем говорит переход в состояние выполнения со
статусом 0, т.е. без ошибок:
{
"state": "exec",
"status": 0,
"data": null
}
9. Сообщение воркера об окончании работы:
a. Успешное окончание работы:
{
"state": "finish",
"status": 0,
"data": null
}
b. Работа воркера была прервана или закончилась с ошибками:
{
"state": "finish",
"status": 1,
"data": [
"{error_message}",
...
]
}

@ -0,0 +1,165 @@
#! env/bin/python3
import sys
import asyncio
import aiohttp
import logging
from typing import List, Tuple, Any
loop = asyncio.get_event_loop()
SERVER_DOMAIN = "http://127.0.0.1:2007"
LOG_LEVELS = {logging.ERROR: "ERROR",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.DEBUG: "DEBUG",
logging.CRITICAL: "CRITICAL",
}
async def check_server(client: aiohttp.ClientSession):
try:
async with client.get(f"{SERVER_DOMAIN}/") as response:
assert response.status == 200
data = await response.json()
assert data == {"status": "active"}
print(f"Connected to the Calculate Server on {SERVER_DOMAIN}")
return True
except Exception:
print("Can not connect to the calculate server.")
return False
async def get_commands(client: aiohttp.ClientSession):
async with client.get(f"{SERVER_DOMAIN}/commands") as response:
assert response.status == 200
data = await response.json()
return data
async def create_worker(client: aiohttp.ClientSession, command: str,
arguments: List[str]):
worker_args = {"arguments": arguments}
async with client.post(f"{SERVER_DOMAIN}/workers/{command}",
json=worker_args) as response:
assert response.status == 200
data = await response.json()
return data["wid"]
async def start_worker(client: aiohttp.ClientSession, wid: int):
async with client.post(f"{SERVER_DOMAIN}/workers/{wid}/start") as response:
assert response.status == 200
data = await response.json()
return data
async def get_worker_messages(client: aiohttp.ClientSession, wid: int):
async with client.get(f"{SERVER_DOMAIN}/workers/{wid}/messages"
) as response:
assert response.status == 200
data = await response.json()
return data["data"]
async def send_data_to_worker(client: aiohttp.ClientSession, wid: int,
data: Any):
first_try = True
while True:
answer = {"data": data}
try:
async with client.post(f"{SERVER_DOMAIN}/workers/{wid}/send",
json=answer) as response:
assert response.status == 200
data = await response.json()
return
except aiohttp.client_exceptions.ClientOSError:
if first_try:
first_try = False
continue
else:
raise
# return data
async def finish_worker(client: aiohttp.ClientSession, wid: int):
async with client.post(f"{SERVER_DOMAIN}/workers/{wid}/finish"
) as response:
assert response.status == 200
data = await response.json()
return data
async def main():
print("Calculate Console Client 0.0.1")
async with aiohttp.ClientSession(
connector=aiohttp.TCPConnector(force_close=False)) as client:
if not await check_server(client):
return
commands = await get_commands(client)
command, command_args = get_console_command()
message = check_command(command, commands)
if message:
print(message)
return
wid = await create_worker(client, command, command_args)
try:
print("CREATED WORKER ID:", wid)
status_data = await start_worker(client, wid)
if status_data.get("status", None) == "error":
print("ERROR:", status_data.get("description", "NONE"))
return
finished = False
while not finished:
messages = await get_worker_messages(client, wid)
for message in messages:
if message["type"] == "output":
print(f"{LOG_LEVELS[message['level']]}:"
f" {message['msg']}")
elif message["type"] == "input":
print(message["msg"])
input_data = input(">> ")
await send_data_to_worker(client, wid, input_data)
elif (message["type"] == "control"
and message["action"] == "finish"):
finished = True
finally:
await finish_worker(client, wid)
def get_console_command() -> Tuple[str, List[str]]:
offset = 1 if sys.argv[0].endswith("client.py") else 0
if len(sys.argv) < 1 + offset:
command = None
command_args = []
else:
command = sys.argv[offset]
command_args = sys.argv[1 + offset:]
return command, command_args
def check_command(command: str, available_commands: List[str]) -> str:
if command is None:
return "Command is not set."
if command not in available_commands:
return "Command is not available."
return ''
if __name__ == "__main__":
try:
loop.run_until_complete(main())
except KeyboardInterrupt:
print("\r<< Keyboard interrupt.")
loop.close()

@ -0,0 +1,183 @@
#! env/bin/python3
import sys
import requests
import logging
from typing import Tuple, List, Union, Dict
from pprint import pprint
LOG_LEVELS = {logging.ERROR: "ERROR",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.DEBUG: "DEBUG",
logging.CRITICAL: "CRITICAL",
}
def main():
# Обращаемся к корню сервера для получения списка доступных ресурсов.
data, links = get_root_data("http://127.0.0.1:2007")
if data is None:
return
print(f"Connected to {data['name']}")
print("\nRoot links:")
pprint(links)
# Получаем данные о текущей команде.
cl_command, cl_parameters = get_console_command()
print_console_parameters(cl_parameters)
data, links, parameters = find_command(cl_command, links)
if data is None:
return
print_command_info(data)
print_parameters(parameters)
links = start_command_configuration(links)
print("\nConfig links:")
pprint(links)
print("\nSetting Parameters")
print("\nLinks:")
data, links = set_parameters(links, cl_parameters)
pprint(links)
if data is not None:
print_config_errors(data)
print("\nCancel configuration:")
result = cancel_configuration(links)
pprint(result)
def get_root_data(base_url: str) -> Tuple[Union[dict, None],
Union[dict, None]]:
try:
response = requests.get(f"{base_url}/")
if response.status_code == 200:
data = response.json()["data"]
links = response.json()["_links"]
return data, links
else:
detail = response.json()['detail']
print(f"{response.status_code}: {detail}")
except Exception as error:
print("ERROR:", str(error))
return None, None
def find_command(console_command: str, links: dict) -> dict:
find_uri = links["find_command"]["href"].format(
console_command=console_command,
is_gui=0)
response = requests.get(find_uri)
if response.status_code == 200:
json = response.json()
data = json["data"]
links = json["_links"]
parameters = json["_embedded"]["parameters"]
return data, links, parameters
else:
detail = response.json()['detail']
print(f"{response.status_code}: {detail}")
return None, None, None
def get_parameters(links: dict) -> List[dict]:
params_uri = links["parameters"]["href"]
response = requests.get(params_uri)
if response.status_code == 200:
return response.json()
else:
print(f"Response Code: {response.status_code}, {response.text}")
return None
def start_command_configuration(links: dict) -> Union[dict, None]:
configure_uri = links["configure"]["href"]
response = requests.post(configure_uri)
if response.status_code == 200:
json = response.json()
return json["_links"]
else:
print(f"Response Code: {response.status_code}, {response.text}")
return None
def set_parameters(links: dict, parameters: List[dict]) -> Dict[str, dict]:
configure_uri = links["configure"]["href"]
response = requests.patch(configure_uri, json=parameters)
if response.status_code == 200:
json = response.json()
data = json.get("data", None)
links = json["_links"]
return data, links
else:
print(f"Response Code: {response.status_code}, {response.text}")
return None, None
def cancel_configuration(links: dict) -> List[dict]:
configure_uri = links["cancel"]["href"]
response = requests.delete(configure_uri)
if response.status_code == 200:
return response.json()
else:
print(f"Response Code: {response.status_code}, {response.text}")
return None
def get_console_command() -> Tuple[str, List[str]]:
offset = 1 if sys.argv[0].endswith("client.py") else 0
command_args = []
if len(sys.argv) < 1 + offset:
command = None
else:
command = sys.argv[offset].split("/")[-1]
args = sys.argv[1 + offset:]
if len(args) >= 2 and not (len(args) % 2):
for index in range(0, len(args) - len(args) % 2, 2):
id, value = args[index: index + 2]
command_args.append({"id": id.strip("-"),
"value": value})
return command, command_args
def print_console_parameters(parameters: dict):
print("\nConsole parameters:")
for parameter in parameters:
print(f" {parameter['id']} = {parameter['value']}")
def print_command_info(data: dict) -> None:
print(f"\nCurrent command: {data['id']}\n"
f" category: {data['category']}\n"
f" title: {data['title']}")
def print_parameters(parameters_data: list) -> None:
parameters = parameters_data["data"]
print("\nParameters:")
for group in parameters:
print(f" {group['group_id']}")
group_parameters = group["parameters"]
for parameter in group_parameters:
print(f" parameter:\t{parameter['id']}\n"
f" default:\t{parameter['default']}\n")
def print_config_errors(errors: Dict[str, str]) -> None:
print("\nConfiguration errors:")
for parameter_id, error in errors.items():
print(f" {parameter_id}: {error}")
if __name__ == "__main__":
main()

@ -0,0 +1,77 @@
from typing import List, Callable, Dict
from scripts import script_0, script_1, script_2
from collections import OrderedDict
class Command:
'''Тестовый класс команд'''
def __init__(self,
command_id: str,
command: str,
category: str,
title: str,
scripts: List[Callable],
parameters: Dict[str, dict]):
self.id = command_id
self.command = command
self.category = category
self.command = command
self.title = title
self.scripts = scripts
self.parameters = parameters
command_0 = Command(command_id="command_0",
category="Test",
title="Test command number 0",
command="cl-command-0",
scripts=OrderedDict({"script_0": script_0,
"script_1": script_1}),
parameters=[{"group_id": "group_0",
"parameters": [{"id": "value_0",
"default": 253},
{"id": "value_1",
"default": 413}]
}
]
)
command_1 = Command(command_id="command_1",
category="Test",
title="Test command number 1",
command="cl-command-1",
scripts=OrderedDict({"script_0": script_0,
"script_1": script_1,
"script_2": script_2}),
parameters=[{"group_id": "group_1",
"parameters": [{"id": "value_0",
"default": 35},
{"id": "value_1",
"default": 41}]
},
{"group_id": "group_2",
"parameters": [{"id": "value_2",
"default": 11}]
}
]
)
command_2 = Command(command_id="command_2",
category="Test",
title="Test command number 2",
command="cl-command-2",
scripts=OrderedDict({"script_0": script_0,
"script_2": script_2}),
parameters=[{"group_id": "group_0",
"parameters": [{"id": "value_0",
"default": 253}]
},
{"group_id": "group_1",
"parameters": [{"id": "value_2",
"default": 413},
{"id": "value_5",
"default": 23}]
}]
)

@ -0,0 +1,121 @@
# import time
from typing import Union, List, Optional, Dict
from multiprocessing.connection import Listener
from commands import Command
from io_module import IOModule
def daemon(listener: Listener, wid: int, base_dir: str, command: Command):
"""Daemon main function."""
with IOModule(listener, command.id) as io:
io.script = "worker"
try:
# int("lolkek")
parameters = configure(io,
get_default_parameters(command.parameters))
except KeyboardInterrupt:
# Остановлено пользователем.
send_finish(io)
return
except Exception as error:
# Остановлено из-за ошибки.
send_finish(io, errors=[str(error)])
return
try:
for name, script in command.scripts.items():
script_func = script[0]
parameters = configure(io, dict())
io.set_info(f"Running script '{name}'")
io.script = name
script_func(io, parameters)
io.script = "worker"
for num in range(5):
io.set_error(f"test_error_{num}")
raise Exception("critical error LOL")
io.set_error("there is no life after CRITICAL error")
except Exception as error:
io.set_error(str(error))
finally:
io.set_info("Command is done.")
io.send({"type": "finish", "msg": "finishing."})
for num in range(5):
io.set_info(f"After finish message_{num}.")
def configure(io: IOModule, parameters: dict) -> Union[None, dict]:
# Копируем чтобы уже для тестов реализовать возможность сброса.
processing_parameters = parameters.copy()
while True:
errors = []
server_message = io.receive()
state = server_message["state"]
status = server_message["status"]
if state == "config":
if status == 1:
# Сброс параметров.
processing_parameters = parameters.copy()
values: list = server_message.get("data", None)
if values is not None:
# Модифицируем параметры.
errors = modify_parameters(processing_parameters, values)
elif state == "finish":
if status == 0:
# Заканчиваем конфигурацию и возвращаем полученные параметры
# если статус 1.
return parameters
# Останавливаем конфигурацию и работу воркера если статус 1.
raise KeyboardInterrupt()
if errors:
send_config_status(io, errors=errors)
else:
send_config_status(io)
def modify_parameters(current: dict, new: list) -> None:
errors = {}
for parameter in new:
parameter_id = parameter["id"]
parameter_value = parameter["value"]
if parameter_id in current:
try:
parameter_value = int(parameter_value)
current[parameter_id] = parameter_value
except ValueError as error:
errors[parameter_id] = str(error)
else:
errors[parameter_id] = f'Parameter "{parameter_id}" is not found.'
return errors
def send_finish(io: IOModule, errors: Optional[List[str]] = []) -> None:
io.send({"state": "finish",
"status": int(bool(errors)),
"data": errors})
def send_config_status(io: IOModule,
errors: Optional[Dict[str, str]] = {}) -> None:
io.send({"state": "config",
"status": int(bool(errors)),
"data": errors})
def get_default_parameters(parameters_description):
parameters = {}
for group in parameters_description:
for parameter in group["parameters"]:
parameters[parameter["id"]] = parameter["default"]
return parameters

@ -0,0 +1,143 @@
# Copyright 2017 Dan Krause
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import socketserver
import socket
import struct
import json
class IPCError(Exception):
pass
class UnknownMessageClass(IPCError):
pass
class InvalidSerialization(IPCError):
pass
class ConnectionClosed(IPCError):
pass
def _read_objects(sock):
header = sock.recv(4)
if len(header) == 0:
raise ConnectionClosed()
size = struct.unpack('!i', header)[0]
data = sock.recv(size - 4)
if len(data) == 0:
raise ConnectionClosed()
return Message.deserialize(json.loads(data))
def _write_objects(sock, objects):
data = json.dumps([o.serialize() for o in objects])
sock.sendall(struct.pack('!i', len(data) + 4))
sock.sendall(data)
def _recursive_subclasses(cls):
classmap = {}
for subcls in cls.__subclasses__():
classmap[subcls.__name__] = subcls
classmap.update(_recursive_subclasses(subcls))
return classmap
class Message(object):
@classmethod
def deserialize(cls, objects):
classmap = _recursive_subclasses(cls)
serialized = []
for obj in objects:
if isinstance(obj, Message):
serialized.append(obj)
else:
try:
serialized.append(classmap[obj['class']](*obj['args'],
**obj['kwargs']))
except KeyError as e:
raise UnknownMessageClass(e)
except TypeError as e:
raise InvalidSerialization(e)
return serialized
def serialize(self):
args, kwargs = self._get_args()
return {'class': type(self).__name__, 'args': args, 'kwargs': kwargs}
def _get_args(self):
return [], {}
def __repr__(self):
r = self.serialize()
args = ', '.join([repr(arg) for arg in r['args']])
kwargs = ''.join([', {}={}'.format(k, repr(v))
for k, v in r['kwargs'].items()])
name = r['class']
return '{}({}{})'.format(name, args, kwargs)
class Client(object):
def __init__(self, server_address):
self.addr = server_address
if isinstance(self.addr, str):
address_family = socket.AF_UNIX
else:
address_family = socket.AF_INET
self.sock = socket.socket(address_family, socket.SOCK_STREAM)
def connect(self):
self.sock.connect(self.addr)
def close(self):
self.sock.close()
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def send(self, objects):
_write_objects(self.sock, objects)
return _read_objects(self.sock)
class Server(socketserver.ThreadingUnixStreamServer):
def __init__(self, server_address, callback, bind_and_activate=True):
if not callable(callback):
callback = lambda x: []
class IPCHandler(socketserver.BaseRequestHandler):
def handle(self):
while True:
try:
results = _read_objects(self.request)
except ConnectionClosed:
return
_write_objects(self.request, callback(results))
if isinstance(server_address, str):
self.address_family = socket.AF_UNIX
else:
self.address_family = socket.AF_INET
socketserver.TCPServer.__init__(self, server_address, IPCHandler,
bind_and_activate)

@ -0,0 +1,830 @@
Вопросы
-------
1. >> Что является ресурсом?
* commands -- информация о командах, их список, группы, описания и тд.
* workers(?) -- воркеры, исполняющие команды. Сомнительно.
Возможно есть смысл скрыть реализацию, т.е. воркеров, под некоторыми
другими сущностями. Например, execution и configuration.
workers -- мб оставить как дополнительный низкоуровневый интерфейс.
<< Ресурсы: commands, configuration, execution, workers (low_level)
2. >> Что делать с демонами-воркерами, обнаруженными при инциализации сервера,
но не зарегистрированными в БД?
<< Убиваем их и удаляем PID-файлы.
3. >> Что делать с демонами-воркерами, для которых найдены PID-файлы, по PID
есть информация в БД, но WID из имени PID-файла не совпадает с WID в БД.
<< Убиваем воркера, удаляем PID-файл и запись в БД.
4. >> Стоит ли убрать разделение команд и их id?
<< Не стоит.
5. >> Нужна ли возможность сброса текущих параметров конфигурации?
<< Да.
Взаимодействие с воркером с точки зрения сервера
------------------------------------------------
1. Сервер получает от клиента запрос, POST /configuration/{command_id}. Далее:
* Сервер создает объект воркера команды и запускает с его помощью
демона-рабочего, оставляя его в режиме ожидания конфигурационных данных.
* После создания объекта воркера в БД сохраняется основная информация
необходимая для воссоздания объекта воркера в новом процессе сервера. Такая
ситуация может возникнуть при переходе на новую версию сервера:
- int wid -- идентификатор воркера;
- str socket -- путь к сокету для чтения данных воркера;
- int daemon_pid -- PID демона-воркера для взаимодействия с ним.
* В ответ на запрос сервер получает список доступных ресурсов, включающих
в себя в том числе действия воркера, а также URI этих ресурсов и
доступные для этого URI http-методы. На данном этапе:
- configure (patch) -- изменить некоторые параметры конфигурации;
- cancel (delete) -- закончить конфигурацию без продолжения выполнения.
По сути удаляет данную конфигурацию как ресурс;
- execute (post) -- запустить выполнение команды с дефолтной
конфигурацией. По сути удаляет конфигурацию как ресурс и создает
новый ресурс -- выполнение.
2. Сервер получает от клиента запрос PATCH /configuration/{WID}/parameters,
содержащий в теле конфигурационные данные. Далее:
* Формируется конфигурационное сообщение (1a), содержащее в себе список
объектов, описывающих один или несколько параметров команды, которое
затем отправляется воркеру.
* Oжидается ответ от воркера (1a) с информацией о результатах обработки
параметров, найденных ошибках и т.д, который пересылается клиенту.
* Конфигурационный цикл повторяется.
Возможно стоит добавить на стороне сервера таймаут при получении ответа от
воркера.
3. Сервер получает от клиента запрос POST /executions/{WID}, указывающий
закончить конфигурацию команды и начать выполнение.
Далее:
* Формируется сообщение воркеру (3a), сообщающее уже ему об окончании цикла
конфигурации.
* Если есть неисправленные ошибки, воркер отвечает на него сообщением (4b),
содержащим оповещение о том, что он заканчивает работу;
Если ошибок нет, то сообщением (4a), оповещающим об успешном запуске
скрипта команды.
* В случае, когда обработка параметров прошла с ошибками -- сразу после
выдачи сообщения об ошибках конфигурации, воркер отправляет сообщение
об окончании своей работы, содержащее список ошибок (9b) и (?) ждет
подтверждение от сервера (?).
4. Сервер получает от клиента запрос GET /executions/{WID}/output на
получение имеющихся выходных данных воркера. Далее:
* Cервер читает все, что есть на сокете воркера. Сообщения (5a, 5b или 6a)
на сокете разделены нулевым символом '\0'. Собирается список полных
сообщений на сокете, формируется сообщение, содержащее этот список,
которое отправляется клиенту.
5. Сервер получает от клиента запрос PATCH /executions/{WID}/input на передачу
воркеру ввода, который был запрошен последним в одном из сообщений ранее
переданных клиенту через сервер. Далее:
* Сервер формирует сообщение (7a) воркеру с данными ввода и отправляет его
воркеру. Если данных нет, что означает, что ввод был сброшен
пользователем, воркер отправляет серверу сообщение (7b) об некорректном
вводе, а метод input объекта ввода/вывода кидает в скрипте специальное
исключение.
Возможно, необходимо убеждаться в том, что данные успешно получены воркером.
Воркер должен ответить сообщением (8a) об успешном получении данных.
- Если данное сообщение не получено в течение некоторого времени
(таймаута) -- выполняем некоторые действия по диагностике и устранению
последствий ошибки, затем отправляем клиенту сообщение, сообщающее о том,
что воркер закончил работу, и содержащее последние сообщения, полученные
от воркера, если таковые имеются.
- Если сообщение об успешном вводе в воркер получено -- отправляем
соответствующее сообщение клиенту.
6. Работа воркера закончилась в результате успешного выполнения команды,
или было прервано клиентом командой stop, или по причине ошибок, возникших
при выполнении скриптов команды. Далее:
* Воркер отправляет на сервер одно из двух специальных сообщений,
оповещающих об окончании работы:
- Содержащее также информацию об ошибках (9b), если таковые
были, неотправленных по каким-то причинам сообщениях (?) или о факте
выполнения команды stop.
- Содержащие только объявление об окончании выполнения команды, то есть
состояния run.
В зависимости от того, возможно ли чтение из сокета, когда процесс воркера
уже закрыт сервер будет или не будет отправлять сообщение разрешающее
воркеру прекратить работу, но клиент его видеть не должен. По факту
получения конечного сообщения и, возможно, данных вместе с ним, клиенту
будет присылаться специальное сообщение.
Контекст воркера
----------------
worker_state -- состояние воркера;
socket_context -- контекст чтения сокета воркера;
worker_output -- вывод воркера перед его выключением сервера.
Что делать
----------
[*] Проверить возможность чтения сервером данных из сокета уже закрытого
воркера. Если такой возможности нет, предусмотреть обработку завершения и
успешной, и некорректной работы воркера с передачей всех необходимых
сообщений. Сделать это внутри воркера.
Итог: чтение из сокета убитого демона возможно.
[ ] Добавить в воркер цикл конфигурации или его тестовый вариант;
[*] Убрать на стороне интерфейса клиента лишний шаг создания объекта воркера;
[*] Убрать self._runner_process и self._pid_file, они в таком случае не нужны;
[*] PID-файл демона удаляем сразу после чтения;
[ ] Реализовать описанный ниже протокол взаимодействия сервера с воркером;
[ ] Добавить в метод kill воркера чтение всего содержимого сокета перед его
удалением.
[ ] Разработать HAL-формат сообщений об ошибках вместо HTTPException.
[*] Сделать метод read воркера асинхронным.
[ ] На случай если воркер убит через SIGKILL -- поскольку обработку
такого завершения работы воркера в самом воркере организовать невозможно,
добавить дополнительную проверку is_alive воркера и предусмотреть обработку
этой ситуации.
[ ] Добавить главный блок try-catch-finally в воркер. В раздел finally добавить
отправку сообщения об окончании работы воркера. В зависимости от
возможности читать данный из сокета законченного процесса воркера, ждать
или не ждать ответ сервера по поводу окончания работы воркера;
[ ] Добавить предварительно деление воркеров по состояниям, вероятно добавить
WorkersPool;
[ ] Добавить состояния воркера:
- config -- воркер в состоянии конфигурации, Т.е. ждёт значения
параметров или собщение об окончании конфигурации;
- exec -- воркер в процессе выполнения команды;
- input -- воркер в процессе ожидания ввода пользователя;
- finish -- воркер в состоянии завершения некоторого состояния.
[ ] Реализовать предварительно REST API в соответствии с наработками;
[ ] Решить, какое значение должны иметь заголовки Content-Type и Accept:
- application/json;
- application/hal+json;
- application/vnd.cphl+json;
- application/vnd.api+json.
Подумать над версионированием API;
[ ] Добавить в ответы сервера обозначения о кэшируемости и некэшируемости,
проработать кэширование в целом, определить, что должно кэшироваться, а что
нет;
[ ] Проработать механизм остановки взаимодействия сервера с клиентом и
возвращения к нему после переподключения клиента, перезагрузки сервера, а
также при этих обоих событиях;
[ ] Добавить проверку во время инициализации сервера наличия PID-файлов
демонов-воркеров:
- Если такие есть -- проверить, что это за процессы;
- Если такие процессы не существуют или не являются воркерами -- удалить
эти PID-файлы;
- Если процессы существуют, являются воркерами и есть информация о них
в БД -- проверяем соответствие PID и WID.
-- Если все совпадает -- удаляем PID-файлы и далее создаем объекты
воркеров по информации в БД;
-- [Q#3] Если есть несовпадения -- убиваем воркер, PID-файл и
записи в БД;
- [Q#2] Если процессы существуют, являются воркерами, но в БД о них нет
информации -- наверное, удаляем PID-файлы и пытаемся убить этих
воркеров с помощью их PID.
Интерфейс сервера
-----------------
REST-интерфейс (вроде). Представляем состояния воркеров в виде ресурсов или
объектов. В соответствии с HATEAOS присылаем URI на ресурсы и действия воркера,
которые доступны в тот или иной момент. Для этого применяем формат
json-сообщений напоминающий HAL или CPHL (не очень распространенный формат).
Различие от HAL в наличии атрибута "methods" в "_links".
* root -- корневой ресурс, являющийся точкой входа для всего API. Возможно,
стоит создать алиас /index или заменить на него.
- GET / -- получить доступные корневые ресурсы;
* request:
null
* response:
Media-Type: application/hal+json
Cache-Control: public
{
"data": {
// Some information about server.
},
"_links": {
"commands": {
"href": "/commands/"
},
"workers": {
"href": "/workers/"
}
}
}
{
"meta": {
// Server info
},
"links": {
}
}
* сommands -- совокупность данных о командах, доступных для запуска на
сервере.
- GET /commands/ -- получить список команд;
* request:
Media-Type: application/json
{
"gui": {true/false}
}
* response:
Media-Type: application/hal+json
Cache-Control: private
{
"{command_id}": {
"data": {
// Some command info.
},
"_links": {
"self": {
"href": "/commands/{CID}"
},
"parameters": {
"href": "/commands/{CID}/parameters"
},
"cofigure": {
"href": "/configs/{CID}"
}
}
},
...
}
- GET /commands/{command}/ -- получить информацию об указанной команде, a
также ее параметры. Запрос используемый
консольным клиентом;
* request:
null
* response:
Media-Type: application/hal+json
Cache-Control: private
{
"data": {
// Some command info.
},
"_links": {
"self": {
"href": "/commands/{CID}"
},
"parameters": {
"href": "/commands/{CID}/parameters"
}
"cofigure": {
"href": "/configuration/{CID}"
}
},
"_embedded": {
"parameters": {
"data": [
{
"group_id": "{group_id}",
"parameters": [...]
},
...
],
"_links":
"self": {
"href": "/commands/{CID}/parameters"
}
}
}
}
- GET /commands/{CID}/parameters -- получить параметры указанной команды;
* request:
null
* response:
{
"data": [
{
"group_id": "{group_id}",
"parameters": [...]
},
...
],
"_links": {
"self": {
"href": "/commands/{CID}/parameters"
}
}
}
* configs -- совокупность объектов воркеров, находящихся в состоянии
конфигурации.
- POST /configs/{command} -- создать конфигурацию;
201 -- конфигурация уже создана;
404 -- команды с указанным id нет;
400 -- неправильный формат запроса.
* request:
null
* response:
{
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"execute": {
"href": "/executions/{WID}"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
- PATCH /configs/{WID}/parameters -- изменить конфигурацию
указанными в теле значениями;
404 -- конфигурации с указанным
WID нет;
400 -- неправильный формат
запроса.
* request:
[
{"id": "{param_id}", "value": "{param_value}"},
...
]
* response:
{
"data": {
"{param_id}": "{error_message}",
...
},
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
OR
{
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"execute": {
"href": "/executions/{WID}"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
- PUT /configs/{WID}/parameters -- заменить конфигурацию с
использованием указанных в теле
запроса значений. Пустой набор
значений позволяет сбросить
конфигурацию;
* request:
[
{"id": "{param_id}", "value": "{param_value}"},
...
]
* response:
{
"data": {
"{param_id}": "{error_message}",
...
},
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
OR
{
"_links": {
"configure": {
"href": "/configs/{WID}/parameters"
},
"execute": {
"href": "/executions/{WID}"
},
"cancel": {
"href": "/configs/{WID}"
}
}
}
- DELETE /configs/{WID} -- закончить конфигурации без выполнения
команды.
* request:
null
* response:
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
...
]
* executions -- совокупность объектов воркеров, находящихся в состоянии
исполнения.
- POST /executions/{WID} -- создать новое исполнение из конфигурации;
201 -- исполнение создано;
409 -- исполнение с этим WID уже есть;
400 -- неправильный формат запроса.
404 -- конфигурация не найдена.
* request:
null
* response:
- Response Code: 201
{
"_links": {
"output": {
"href": "/executions/{WID}/output"
},
"stop": {
"href": "/executions/{WID}"
}
}
}
- DELETE /executions/{WID} -- удалить исполнение, происходит путем
отправки в скрипт SIGINT;
* request:
null
* response (возвращает список непереданных сообщений, если они есть):
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
...
]
- GET /executions/{WID}/output -- получить список имеющихся сообщений от
команды;
* request:
null
* response (необходимость остановить выполнение определяется по
Response Code):
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
{
"data": {
"type": "input",
"text": "{input_message}"
"source": "{source_script}"
},
"_links": {
"input": {
"href": "/executions/{WID}/input",
}
}
}
]
- PATCH /executions/{WID}/input -- отправить в воркер некоторый ввод;
* request (если null, значит ввод прерывается):
{
"data": {input_data/null}
}
* response:
- Response Code: 200
null
- Response Code: 404
{
"error": "Execution {WID} is not found"
}
- Response Code: 400
{
"error": "Input message is not correct"
}
* workers -- вся совокупность воркеров.
- GET /workers/ -- получить список демонов-воркеров и информацию о них;
* request:
null
* response:
{
"{WID}": {
"data": {
"socket": "{socket_path}",
"command": "{worker_command}",
"pid": "{daemon_pid}",
}
"_links": {
"kill": {
"href": "/workers/{WID}",
"methods": ["delete"]
}
}
},
...
}
- DELETE /workers/{WID} -- отправить сигнал SIGKILL демону-воркеру;
* request:
null
* response (сообщения полученные от убитого воркера):
[
{
"data": {
"type": "output",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}",
}
},
...
]
Протокол взаимодействия воркера и сервера
-----------------------------------------
Все сообщения представляют собой json-структуры, включающие в себя 3 поля:
- state -- состояние воркера:
* Если сообщение пришло воркеру от сервера, тогда это поле указывает
воркеру, какое состояние он должен принять;
* Если сообщение пришло серверу от воркера, тогда это поле указывает на
текущее состояние воркера.
- status -- статус текущего состояния:
0 -- состояние корректно;
1 -- состояние некорректно.
- data -- некоторые данные, структура которых может быть любой.
1. Данные конфигурации от клиента.
a. Продолжить конфигурацию указанными значениями:
{
"state": "config",
"status": 0,
"data": [
{"id": "{param_id}", "value": "{parameter_value}"},
...
]
}
b. Сбросить конфигурацию и продолжить указанными значениями:
{
"state": "config",
"status": 1,
"data": [
{"id": "{param_id}", "value": "{parameter_value}"},
...
]
}
2. Ответ воркера по данным конфигурации.
a. Все параметры конфигурации корректны:
{
"state": "config",
"status": 0,
"data": null
}
b. Некоторые параметры конфигурации некорректны:
{
"state": "config",
"status": 1,
"data": {
"{param_id}": "{error_message}",
...
}
}
3. Запрос на окончание конфигурации от клиента.
a. Закончить конфигурацию, начать выполнение команды:
{
"state": "finish",
"status": 0,
"data": null
}
b. Закончить конфигурацию и работу воркера:
{
"state": "finish",
"status": 1,
"data": null
}
4. Ответ воркера на запрос об окончании конфигурации:
a. Закончить конфигурацию, начать выполнение команды:
{
"state": "finish",
"status": 0,
"data": null
}
b. Закончить конфигурацию и работу воркера:
{
"state": "finish",
"status": 1,
"data": {
"{param_id}": "{error_message}",
...
}
}
5. Сообщение с выводом воркера.
a. Сообщение из скрипта при его "штатной" работe:
{
"state": "exec",
"status": 0,
"data": {
"type": "message",
"logging": {logging_level/null},
"text": "{message_text}",
"source": "{source_script}"
}
}
b. Сообщение об ошибке приводящей к завершению выполнения скрипта и
команды:
{
"state": "exec",
"status": 1,
"data": {
"error": "{error_message}",
"source": "{source_script}"
}
}
6. Сообщение с запросом на ввод.
a. Состояние "input" -- воркер в состоянии ожидания ввода:
{
"state": "input",
"status": 0,
"data": {
"text": "{message_text}",
"source": "{source_script}"
}
}
7. Сообщение от клиента с данными.
a. Ввод успешно выполнен и передается:
{
"state": "input",
"status": 0,
"data": "{client_data}"
}
b. Ввод данных прерван, данных не будет:
{
"state": "input",
"status": 1,
"data": null
}
8. Сообщение от воркера о получении ввода.
a. Ввод успешно получен, о чем говорит переход в состояние выполнения со
статусом 0, т.е. без ошибок:
{
"state": "exec",
"status": 0,
"data": null
}
9. Сообщение воркера об окончании работы:
a. Успешное окончание работы:
{
"state": "finish",
"status": 0,
"data": null
}
b. Работа воркера была прервана или закончилась с ошибками:
{
"state": "finish",
"status": 1,
"data": [
"{error_message}",
...
]
}

@ -0,0 +1,100 @@
import json
import logging
from multiprocessing.connection import Listener, Connection
from typing import Union
class WorkerIOError(KeyboardInterrupt):
pass
class IOModule:
'''Класс модуля ввода/вывода для воркеров.'''
def __init__(self, listener: Listener, command_name: str):
self._listener: Listener = listener
self._connection: Union[Connection, None] = None
self._command: str = command_name
self._script: str = ""
@property
def command(self) -> str:
return self._command
@property
def script(self) -> str:
return self._script
@script.setter
def script(self, script: str) -> None:
self._script = script
def set_debug(self, msg: str) -> None:
self.output(msg, level=logging.DEBUG)
def set_info(self, msg: str) -> None:
self.output(msg, level=logging.INFO)
def set_warning(self, msg: str) -> None:
self.output(msg, level=logging.WARNING)
def set_error(self, msg: str) -> None:
self.output(msg, level=logging.ERROR)
def set_critical(self, msg: str) -> None:
self.output(msg, level=logging.CRITICAL)
def output(self, text: str, level: int = logging.INFO) -> None:
'''Метод для отправки серверу вывода с указанием уровня.'''
output_request = {"state": "exec",
"status": 0,
"data": {
"type": "message",
"logging": level,
"text": text,
"source": self._script
}
}
self.send(output_request)
def input(self, msg: str) -> str:
'''Метод через который возможен ввод данных в скрипт.'''
input_request = {"state": "input",
"status": 0,
"data": {"text": msg,
"source": f"{self.command}:{self.script}"}}
answer = None
while answer is None:
self.send(input_request)
answer = self.receive()
return answer['data']
def send(self, data: dict) -> None:
'''Метод для отправки данных серверу.'''
if self._connection is None:
raise WorkerIOError("No one is connected now.")
data = json.dumps(data).encode()
self._connection.send_bytes(data)
def receive(self) -> dict:
'''Метод для получения данных от сервера.'''
if self._connection is None:
raise WorkerIOError("No one is connected now.")
data: bytes = self._connection.recv_bytes()
return json.loads(data.decode())
def __enter__(self):
self._connection = self._listener.accept()
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
if exc_type:
print("exc_type =", exc_type)
print("exc_value =", exc_value)
print("exc_traceback =", exc_traceback)
if exc_type is KeyboardInterrupt:
print("correctly")
if not self._connection.closed:
self._connection.close()

@ -0,0 +1,202 @@
from typing import Any, List, Dict, Optional, Literal, Union
from pydantic import BaseModel, Field, HttpUrl
# from utils import ResponseStructure
# from pprint import pprint
# import json
# from fastapi import HTTPException, status
class NotTemplatedLinkData(BaseModel):
href: HttpUrl = "URI"
class Config:
extra = "forbid"
class TemplatedLinkData(NotTemplatedLinkData):
templated: bool = False
LinkData = Union[NotTemplatedLinkData, TemplatedLinkData]
# GET /
class ServerInfo(BaseModel):
name: str = "Server name"
version: str = "Server version"
class GetRootResponse(BaseModel):
data: ServerInfo
links: Dict[str, LinkData] = Field(..., alias="_links",
description="Links to resources")
# GET /commands/{CID}/parameters
class ParameterInfo(BaseModel):
id: str
default: Any
class ParametersGroup(BaseModel):
group_id: str
parameters: List[ParameterInfo]
class GetCommandParametersResponse(BaseModel):
data: List[ParametersGroup]
links: Dict[str, LinkData] = Field(..., alias="_links")
# GET /commands/{CID}
class CommandData(BaseModel):
id: str
title: str
category: str
command: str
class CommandInfo(BaseModel):
data: CommandData
links: Dict[str, LinkData] = Field(..., alias="_links")
class FoundCommandInfo(CommandInfo):
embedded: Dict[str,
GetCommandParametersResponse] = Field(...,
alias="_embedded")
# GET /commands/
class GetCommandsResponse(BaseModel):
__root__: Dict[str, CommandInfo]
# class Config:
# @staticmethod
# def schema_extra(schema, model):
# example = {}
# for num in range(2):
# resp = ResponseStructure()
# resp.add_data(id=f"command_{num}",
# command=f"cl-command-{num}",
# title=f"Command {num}",
# category="cool-category"
# )
# resp.add_link("parameters",
# f"/commands/command_{num}/parameters")
# resp.add_link("configure", f"/configs/command_{num}")
# example[f"command_{num}"] = resp.get_dict()
# schema["example"] = example
# POST /configs/{command}
class CreateConfigResponse(BaseModel):
links: Dict[str, LinkData] = Field(..., alias="_links")
# PATCH /configs/{WID}/parameters
class ParameterValue(BaseModel):
id: str
value: Any
class ModifyConfigRequest(BaseModel):
__root__: List[ParameterValue]
class ModifyConfigCorrectResponse(BaseModel):
links: Dict[str, LinkData] = Field(..., alias="_links")
class Config:
extra = "forbid"
class ModifyConfigUncorrectResponse(ModifyConfigCorrectResponse):
data: Optional[Dict[str, str]]
links: Dict[str, LinkData] = Field(..., alias="_links")
ModifyConfigResponse = Union[ModifyConfigCorrectResponse,
ModifyConfigUncorrectResponse]
# PUT /configs/{WID}/parameters
class ResetConfigRequest(BaseModel):
__root__: List[ParameterValue]
class ResetConfigResponse(BaseModel):
data: Optional[Dict[str, str]]
links: Dict[str, LinkData] = Field(..., alias="_links")
# POST /executions/{WID}
class CreateExecutionResponse(BaseModel):
links: Dict[str, LinkData] = Field(..., alias="_links")
# DELETE /executions/{WID}
class OutputData(BaseModel):
type: Literal["output"]
logging: Union[int, None]
text: str
source: str
class OutputMessage(BaseModel):
data: OutputData
class StopExecutionsResponse(BaseModel):
__root__: List[OutputMessage]
# DELETE /configs/{WID}
class StopConfigResponse(BaseModel):
__root__: List[OutputMessage]
# GET /executions/{WID}/output
class InputRequestData(BaseModel):
type: Literal["input"]
text: str
source: str
class InputMessage(BaseModel):
data: InputRequestData
links: Dict[str, LinkData] = Field(..., alias="_links")
class GetExecutionOutputResponse(BaseModel):
__root__: List[Union[OutputMessage, InputMessage]]
# PATCH /executions/{WID}/input
class WriteToExecutionRequest(BaseModel):
data: Any
# GET /workers/
class WorkerData(BaseModel):
socket: str
command: str
pid: int
class WorkerInfo(BaseModel):
data: WorkerData
links: Dict[str, LinkData] = Field(..., alias="_links")
class GetWorkersResponse(BaseModel):
__root__: Dict[int, WorkerInfo]
# DELETE /workers/{WID}
# Сообщения протокола взаимодействия сервера и воркеров.
class WorkerMessageBase(BaseModel):
state: str
status: int

@ -0,0 +1,55 @@
from io_module import IOModule
import time
def script_0(io: IOModule, parameters: dict) -> None:
try:
io.set_info(f"PARAMETERS: {parameters}")
value_0 = get_integer(0, parameters, io)
value_1 = get_integer(1, parameters, io)
time.sleep(2)
result = value_0 + value_1
io.set_info(f"Script executed with result = {result}")
except Exception as error:
io.set_error(f"{str(error)}")
def script_1(io: IOModule, parameters: dict) -> None:
try:
io.set_info(f"PARAMETERS: {parameters}")
value_0 = get_integer(0, parameters, io)
value_1 = get_integer(1, parameters, io)
time.sleep(3)
result = value_0 * value_1
io.set_info(f"Script executed with result = {result}")
except Exception as error:
io.set_error(f"{str(error)}")
def script_2(io: IOModule, parameters: dict) -> None:
try:
io.set_info(f"PARAMETERS: {parameters}")
value_0 = get_integer(0, parameters, io)
value_1 = get_integer(1, parameters, io)
time.sleep(4)
result = value_0 ** value_1
io.set_info(f"Script executed with result = {result}")
except Exception as error:
io.set_error(f"{str(error)}")
def get_integer(index: int, parameters: dict, io: IOModule) -> int:
io.set_info(f"Looking for 'value_{index}'")
value = parameters.get(f"value_{index}", None)
if value is None:
io.set_warning(f"'value_{index}' not found")
value = io.input(f"Enter 'value_{index}'")
else:
io.set_info(f"value_{index} = {value}")
return int(value)

@ -0,0 +1,282 @@
import asyncio
# import logging
import uvicorn
from starlette.responses import JSONResponse
from starlette.requests import Request
from fastapi import FastAPI, status, HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic.main import ModelMetaclass
from pydantic import parse_obj_as
from typing import _GenericAlias, Union, Optional
from commands import command_0, command_1, command_2
from utils import ResponseStructure, Commands
from worker import (
WorkersManager,
UnexpectedWorkerFinish,
UnexpectedWorkerState,
DaemonIsDead
)
from schemas import (
GetRootResponse,
GetCommandsResponse,
FoundCommandInfo,
GetCommandParametersResponse,
CreateConfigResponse,
ModifyConfigRequest,
ModifyConfigResponse,
ResetConfigRequest,
ResetConfigResponse,
StopConfigResponse,
CreateExecutionResponse
)
from pprint import pprint
api = FastAPI(title="Calculate Server")
workers = WorkersManager(loop=asyncio.get_event_loop())
commands = Commands(command_0,
command_1,
command_2)
@api.on_event("startup")
async def startup_event():
check_pid_files()
@api.on_event("shutdown")
async def shutdown_event():
workers.clean()
@api.get("/", response_model=GetRootResponse, tags=["Home"])
async def get_primary_commands(request: Request):
resp = ResponseStructure(get_base_url(request))
resp.add_data(name="Calculate Server", version="0.1")
resp.add_link("commands", "/commands?gui={is_gui}", templated=True)
resp.add_link("find_command", "/commands/{console_command}?gui={is_gui}",
templated=True)
resp.add_link("workers", "/workers")
return validate_response(resp.get_dict(), GetRootResponse,
media_type="application/hal+json")
@api.get("/commands", response_model=GetCommandsResponse, tags=["Commands"])
async def get_available_commands(request: Request,
gui: Optional[bool] = False):
response_data = commands.get_commands(get_base_url(request))
return validate_response(response_data, GetCommandsResponse,
media_type="application/hal+json")
@api.get("/commands/{command}",
response_model=FoundCommandInfo,
tags=["Commands"])
async def find_command_data(command: str, request: Request,
gui: Optional[bool] = False,
by_id: Optional[bool] = False):
base_url = get_base_url(request)
if by_id:
command_data = commands.get_by_id(command, base_url)
if command_data is None:
raise get_command_not_found(command)
else:
command_data = commands.find_command(command, base_url)
if command_data is None:
raise get_cl_command_not_found(command)
return validate_response(command_data, FoundCommandInfo,
media_type="application/hal+json")
@api.get("/commands/{command_id}/parameters",
response_model=GetCommandParametersResponse,
tags=["Commands"])
async def get_command_parameters(command_id: str, request: Request):
parameters_data = commands.get_parameters(command_id,
get_base_url(request))
if parameters_data is None:
raise get_command_not_found(command_id)
return parameters_data
@api.post("/configs/{command_id}", response_model=CreateConfigResponse,
tags=["Configurations"])
async def create_configuration(command_id: str, request: Request):
command = commands.get_command_object(command_id)
if command is None:
raise get_command_not_found(command_id)
print("Making worker")
worker_id, error = await workers.make_worker(command)
if error is not None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error)
resp = ResponseStructure(get_base_url(request))
resp.add_link("configure", f"/configs/{worker_id}/parameters")
resp.add_link("execute", f"/execs/{worker_id}")
resp.add_link("cancel", f"/configs/{worker_id}")
return validate_response(resp.get_dict(), CreateConfigResponse)
@api.patch("/configs/{wid}/parameters", tags=["Configurations"])
async def modify_configuration_parameters(wid: int,
parameters: ModifyConfigRequest,
request: Request):
response = await change_config(get_base_url(request), wid, parameters)
return validate_response(response, ModifyConfigResponse)
@api.put("/configs/{wid}/parameters", tags=["Configurations"])
async def reset_configuration_parameters(wid: int,
parameters: ResetConfigRequest,
request: Request):
response = await change_config(get_base_url(request), wid, parameters,
reset=True)
return validate_response(response, ResetConfigResponse)
async def change_config(base_url: str, wid: int,
parameters: ModifyConfigRequest,
reset: bool = False) -> dict:
parameters = parameters.dict()["__root__"]
try:
result = await workers.configure_worker(wid, parameters, reset=reset)
except UnexpectedWorkerFinish as error:
return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=("Unexpected configuration finish. Reason:"
f" {error.reason[0]}"))
except UnexpectedWorkerState as error:
print("Unexpected worker state.")
return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=('Unexpected configuration state:'
f' "{error.state}". Expected:'
f' "{error.expected}"'))
except DaemonIsDead:
print("DAEMON IS DEAD")
return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Daemon is dead")
print("RESULT:")
pprint(result)
if result is None:
raise get_configuration_not_found(wid)
resp = ResponseStructure(base_url)
resp.add_link("configure", f"configs/{wid}/parameters")
resp.add_link("reset", f"configs/{wid}/parameters")
resp.add_link("cancel", f"configs/{wid}")
if not result:
resp.add_link("execute", f"/execs/{wid}")
else:
resp.add_data(result)
return resp.get_dict()
@api.delete("/configs/{wid}", response_model=StopConfigResponse,
tags=["Configurations"])
async def stop_configuration(wid: int):
output = await workers.cancel_worker_configuration(wid)
if output is None:
raise get_configuration_not_found(wid)
return []
@api.post("/execs/{wid}", tags=["Executions"])
async def start_command_execution(wid: int, request: Request):
result = workers.run_execution(wid)
if result > 0:
raise get_configuration_not_found(wid)
elif result < 0:
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
detail=f"Execution id = {wid} already exists.")
resp = ResponseStructure(get_base_url(request))
resp.add_link("configure", f"/configs/{wid}/parameters")
resp.add_link("reset", f"/configs/{wid}/parameters")
resp.add_link("cancel", f"/configs/{wid}")
return validate_response(resp.get_dict(), CreateExecutionResponse)
@api.delete("/execs/{wid}", tags=["Executions"])
async def stop_command_execution(wid: int):
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="'stop execution' is not implemented")
@api.get("/execs/{wid}/output", tags=["Executions"])
async def read_execution_output(wid: int):
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="'stop execution' is not implemented")
@api.patch("/execs/{wid}/input", tags=["Executions"])
async def write_to_execution(wid: int):
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="'write to execution' is not implemented")
def check_pid_files(pid_files_dir: str = "./") -> None:
pass
def get_base_url(request: Request):
base_url = f"{request.base_url.scheme}://{request.base_url.netloc}"
return base_url
def get_command_not_found(command_id: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=(f'Command with id "{command_id}"'
' is not found.'))
def get_cl_command_not_found(console_command: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=(f'Console command "{console_command}" is not'
' found.'))
def get_configuration_not_found(wid: int) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=f"Configuration with id={wid} is not found.")
def validate_response(data: Union[dict, list],
schema: Union[ModelMetaclass, _GenericAlias, type],
media_type: Optional[str] = None,
status_code: int = 200
) -> JSONResponse:
"""Функция для валидации данных ответа сервера по указанной схеме с учетом
наличия псевдонимов полей и возможности того, что схема задана обычным
типом, типом из typing или BaseModel с __root__. Возвращает объект ответа.
"""
if isinstance(schema, ModelMetaclass):
if "__root__" in schema.__annotations__:
validated = schema(__root__=data)
else:
validated = schema(**data)
elif isinstance(schema, (_GenericAlias, type)):
validated = parse_obj_as(schema, data)
return JSONResponse(content=jsonable_encoder(validated, by_alias=True),
media_type=media_type, status_code=status_code)
if __name__ == "__main__":
uvicorn.run("server:api", host="127.0.0.1", port=2007, reload=True)

@ -0,0 +1,199 @@
from fastapi import status, HTTPException
from fastapi.encoders import jsonable_encoder
from starlette.responses import JSONResponse
from starlette.requests import Request
from pydantic.main import ModelMetaclass
from pydantic import parse_obj_as
from typing import Dict, Union, List, _GenericAlias, Optional
from commands import Command
# from pprint import pprint
class ResponseStructure:
'''Класс структуры данных, для получения HATEAOS ответа на запросы
клиента.'''
def __init__(self, base_url: str):
self._base_url: str = base_url.strip("/")
self._data: dict = dict()
self._links: Dict[str, Dict[str, str]] = dict()
self._embedded: Dict[str, dict] = dict()
def get_dict(self) -> dict:
dictionary = dict()
if self._data:
dictionary["data"] = self._data
if self._links:
dictionary["_links"] = self._links
if self._embedded:
dictionary["_embedded"] = self._embedded
return dictionary
def add_data(self, *args, **kwargs) -> "ResponseStructure":
if args:
self._data = args[0]
if kwargs:
self._data.update(kwargs)
return self
def embed(self, resource: str,
content: Union[dict, list]) -> "ResponseStructure":
self._embedded[resource] = content
return self
def add_link(self, action: str, uri: str,
templated: bool = False) -> "ResponseStructure":
uri = self._get_uri(uri)
if action in self._links:
link_section = self._links[action]
if link_section["href"] != uri:
link_section["href"] = uri
else:
link_section = {"href": uri}
self._links[action] = link_section
if templated:
print("TEMPLATED:", action)
link_section["templated"] = templated
return self
def _get_uri(self, uri: str) -> str:
if uri.startswith(self._base_url):
return uri
return f"{self._base_url}/{uri.strip('/')}"
class Commands:
'''Предварительная реализация контейнера описаний команд.'''
def __init__(self, *commands: List[Command]):
self._by_id: dict = dict()
self._by_command: dict = dict()
for command in commands:
self._by_id[command.id] = command
self._by_command[command.command] = command
def get_commands(self, base_url: str) -> Dict[str, dict]:
response = dict()
for command in self._by_id.values():
data = ResponseStructure(base_url)
data.add_data(id=command.id,
title=command.title,
category=command.category,
command=command.command)
data.add_link("self", f"/commands/{command.id}?by_id=true")
data.add_link("parameters", f"/commands/{command.id}/parameters")
data.add_link("configure", f"/configs/{command.id}")
response[command.id] = data.get_dict()
return response
def get_by_id(self, command_id: str, base_url: str) -> Dict[str, Dict]:
if command_id in self._by_id:
command = self._by_id[command_id]
data = ResponseStructure(base_url)
data.add_data(id=command.id,
title=command.title,
category=command.category,
command=command.command)
data.add_link("self", f"/commands/{command.id}?by_id=1")
data.add_link("parameters", f"/commands/{command.id}/parameters")
data.add_link("configure", f"/configs/{command.id}")
parameters_data = self._get_parameters_data(command.parameters,
command.id, base_url)
data.embed("parameters", parameters_data)
return data.get_dict()
return None
def find_command(self, console_command: str,
base_url: str) -> Union[dict, None]:
if console_command in self._by_command:
command = self._by_command[console_command]
data = ResponseStructure(base_url)
data.add_data(id=command.id,
title=command.title,
category=command.category,
command=command.command)
data.add_link("self", f"/commands/{command.id}?by_id=1")
data.add_link("parameters", f"/commands/{command.id}/parameters")
data.add_link("configure", f"/configs/{command.id}")
parameters_data = self._get_parameters_data(command.parameters,
command.id, base_url)
data.embed("parameters", parameters_data)
return data.get_dict()
else:
return None
def get_parameters(self, command_id: str, base_url: str
) -> Union[dict, None]:
if command_id in self._by_id:
command = self._by_id[command_id]
parameters_data = self._get_parameters_data(command.parameters,
command.id, base_url)
return parameters_data
else:
return None
def get_command_object(self, command_id: str) -> Command:
if command_id in self._by_id:
return self._by_id[command_id]
return None
def _get_parameters_data(self, parameters: list, command_id: str,
base_url: str) -> List[dict]:
data = ResponseStructure(base_url)
data.add_data(parameters)
data.add_link("self", f"/commands/{command_id}/parameters")
return data.get_dict()
def get_base_url(request: Request):
base_url = f"{request.base_url.scheme}://{request.base_url.netloc}"
return base_url
def get_command_not_found(command_id: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=(f'Command with id "{command_id}"'
' is not found.'))
def get_cl_command_not_found(console_command: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=(f'Console command "{console_command}" is not'
' found.'))
def get_configuration_not_found(wid: int) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=f"Configuration with id={wid} is not found.")
def validate_response(data: Union[dict, list],
schema: Union[ModelMetaclass, _GenericAlias, type],
media_type: Optional[str] = None,
status_code: int = 200
) -> JSONResponse:
"""Функция для валидации данных ответа сервера по указанной схеме с учетом
наличия псевдонимов полей и возможности того, что схема задана обычным
типом, типом из typing или BaseModel с __root__. Возвращает объект ответа.
"""
if isinstance(schema, ModelMetaclass):
if "__root__" in schema.__annotations__:
validated = schema(__root__=data)
else:
validated = schema(**data)
elif isinstance(schema, (_GenericAlias, type)):
validated = parse_obj_as(schema, data)
return JSONResponse(content=jsonable_encoder(validated, by_alias=True),
media_type=media_type, status_code=status_code)

@ -0,0 +1,413 @@
import os
import sys
import json
import struct
import signal
import asyncio
import logging
from multiprocessing import Process
from multiprocessing.connection import Listener
# import logging
# import time
from daemon import daemon
from commands import Command
from typing import Dict, Optional, List, Union, Tuple, Literal
from asyncio.unix_events import _UnixSelectorEventLoop
from asyncio import get_event_loop
LOG_LEVELS = {logging.ERROR: "ERROR",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.DEBUG: "DEBUG",
logging.CRITICAL: "CRITICAL",
}
class WorkerException(Exception):
pass
class UnexpectedWorkerFinish(WorkerException):
def __init__(self, reason: List[str], output: Optional[List[str]] = []):
"""Исключение кидаемое в случае, если воркер неожиданно умер."""
self.reason: List[str] = reason
self.output: List[dict] = output
class UnexpectedWorkerState(WorkerException):
"""Исключение кидаемое в случае, если состояние воркера не соответствует
ожидаемому."""
def __init__(self, state: str, expected: str):
self.state: str = state
self.expected: str = expected
class DaemonIsDead(WorkerException):
"""Исключение кидаемое в случае, если демон-воркер неожиданно оказался
мертв."""
def __init__(self, info: str, output: Optional[List[str]] = []):
self.info: str = info
self.output: List[dict] = output
class IncorrectConfiguration(WorkerException):
"""Исключение кидаемое при попытке запустить выполнение конфигурации, в
которой есть ошибки."""
def __init__(self, errors: Dict[str, str]):
self._errors = errors
class WorkersManager:
def __init__(self, loop: Optional[_UnixSelectorEventLoop] = None):
if loop is None:
self._loop = get_event_loop()
else:
self._loop = loop
self._configs: Dict[int, WorkerProtocol] = {}
self._execs: Dict[int, WorkerProtocol] = {}
self._wids: List[int] = []
async def make_worker(self, command: Command) -> Tuple[Union[int, None],
Union[str, None]]:
"""Метод для создания воркеров."""
wid = self._get_wid()
try:
# Создаем воркера и получаем интерфейс для взаимодействия с ним.
worker_api = await self._create_worker(wid, command)
self._configs[wid] = worker_api
except Exception as error:
if wid in self._configs:
self._configs.pop(wid)
return None, str(error)
return wid, None
async def configure_worker(self, wid: int, parameters: List[dict],
reset: bool = False) -> Union[dict, None]:
"""Метод для модификации или сброса параметров конфигурации указанными
значениями.
"""
if not parameters:
return {}
if wid not in self._configs:
return None
worker_api: WorkerProtocol = self._configs[wid]
try:
result = await worker_api.configure(parameters)
except (UnexpectedWorkerFinish, DaemonIsDead):
worker_api.kill()
self._configs.pop(wid)
raise
return result
async def cancel_worker_configuration(self, wid: int):
"""Метод для завершения конфигурации воркера и завершения его работы.
"""
if wid not in self._configs:
return None
worker_api: WorkerProtocol = self._configs[wid]
output = await worker_api.cancel_configuration()
worker_api.delete_socket()
self._configs.pop(wid)
return output
def clean(self):
"""Метод для убийства всех работающих на данный момент демонов."""
# TODO доработать его логику так, чтобы при перезапуске сервера этот
# метод никого не убивал.
for wid, worker in {**self._configs, **self._execs}.items():
result = False
try:
result = worker.kill()
finally:
if result:
print(f"[*] Worker {wid} is killed.")
else:
print(f"[x] Worker {wid} is not killed.")
continue
def get_config(self, wid: int):
if wid in self._configs:
return self._configs[wid]
return None
def get_execution(self, wid: int):
if wid in self._execs:
return self._execs[wid]
return None
def run_execution(self, wid: int) -> Tuple[int, Union[dict, None]]:
'''Метод для запуска исполнения команды, используя указанную
конфигурацию.
Вывод:
0, None -- исполнение успешно запущено;
1, None -- конфигурация с данным wid не найдена;
2, errors -- конфигурация с данным wid содержит ошибки;
3, None -- исполнение с данным wid уже существует;
'''
if wid not in self._configs:
return 1, None
if self._configs[wid].status > 0:
# Берем ошибки, как-то.
errors = {}
return 2, errors
if wid in self._execs:
return 3, None
# worker = self._configs.pop(wid)
return 0, None
async def _create_worker(self, wid: int, command: Command
) -> "WorkerProtocol":
# Путь к временному файлу, в котором будет лежать pid демона-рабочего.
pid_file_path: str = os.path.join(os.getcwd(), f"pid_file_{wid}")
socket_path: str = os.path.join(os.getcwd(),
f"worker_{wid}.sock")
if os.path.exists(socket_path):
os.remove(socket_path)
process_runner = Process(target=daemonize_worker,
args=(socket_path, wid, pid_file_path,
command)
)
process_runner.start()
process_runner.join()
daemon_pid = await self._get_daemon_pid(pid_file_path)
self._delete_pid_file(pid_file_path)
transport, worker_interface = await self._loop.create_unix_connection(
lambda: WorkerProtocol(daemon_pid,
socket_path),
socket_path)
return worker_interface
def _get_wid(self):
if not self._wids:
worker_id = 0
else:
worker_id = self._wids[-1] + 1
self._wids.append(worker_id)
return worker_id
async def _get_daemon_pid(self, pid_file_path: str) -> int:
while True:
if not os.path.exists(pid_file_path):
# Если файла нет -- уступаем работу другим воркерам.
await asyncio.sleep(0)
with open(pid_file_path, "r") as pid_file:
daemon_pid = int(pid_file.readline())
return daemon_pid
def _delete_pid_file(self, pid_file_path: str) -> None:
try:
os.remove(pid_file_path)
except OSError:
pass
def __repr__(self):
configs = ["Configurations:"]
configs.extend([f"wid={wid}" for wid in self._configs.keys()])
configs_repr = "\n\t".join(configs)
execs = ["Executions:"]
execs.extend([f"wid={wid}" for wid in self._execs.keys()])
execs_repr = "\n\t".join(execs)
return f"{configs_repr}\n{execs_repr}"
def daemonize_worker(socket_path: str, wid: int, pid_file: str,
command: Command) -> None:
'''Функция запускаемая в отдельном процессе и порождающая демона при помощи
магии двойного форка.'''
base_dir = os.getcwd()
new_fork()
os.chdir("/")
os.setsid()
os.umask(0)
new_fork()
# Создаем файл, с помощью которого будем передавать pid демона-рабочего.
daemon_pid = str(os.getpid())
with open(pid_file, "w") as pid_file:
pid_file.write(daemon_pid)
with Listener(socket_path) as listener:
daemon(listener, wid, base_dir, command)
def new_fork() -> None:
'''Функция для создания форка текущего процесса.'''
try:
middle_pid = os.fork()
if middle_pid > 0:
sys.exit(0)
except OSError as error:
print(f"FORK ERROR: {error.errno} - {str(error)}")
sys.exit(1)
class WorkerProtocol(asyncio.Protocol):
"""Класс протокола управления воркером."""
def __init__(self, pid: int, socket_path: str):
self.loop = asyncio.get_running_loop()
self._pid: int = pid
self._socket_path: str = socket_path
# Объект Event, предназначенный для остановки записи при переполнении
# буффера записи в трспорте.
self._can_write = asyncio.Event()
self._can_write.clear()
self._msg_queue = asyncio.Queue()
# Контекст чтения данных воркера.
self._buffer = bytearray()
self._msg_size: int = 0
self._connection_closed: bool = True
async def configure(self, parameters: List[dict],
reset: bool = False) -> list:
"""Метод для конфигурации воркера указанными значениями параметров.
Возвращает список ошибок, если таковые есть, или пустой список, если
никаких ошибок нет.
"""
message = self._make_worker_message("config", int(reset), parameters)
await self._write(message)
response = await self._read()
response = json.loads(response)
return response["data"]
async def cancel_configuration(self) -> List[dict]:
"""Метод для остановки конфигурации воркера и его работы в целом."""
stop_message: bytes = self._make_worker_message("finish", 1, None)
await self._write(stop_message)
response = await self._read()
response = json.loads(response)
return response["data"]
def connection_made(self, transport: asyncio.Transport) -> None:
self.transport = transport
self._can_write.set()
self._connection_closed: bool = False
print("Connection made.")
def connection_lost(self, exc: BaseException) -> None:
if exc:
print("Connection lost. Error:", str(exc))
else:
print("Connection lost.")
self._connection_closed: bool = True
def data_received(self, data: bytes) -> None:
self._buffer.extend(data)
while True:
if not self._msg_size:
if len(self._buffer) < 4:
return
size_length = struct.calcsize(">i")
self._msg_size, = struct.unpack(">i",
self._buffer[:size_length])
self._buffer = self._buffer[size_length:]
if len(self._buffer) >= self._msg_size:
data = self._buffer[:self._msg_size]
self._buffer = self._buffer[self._msg_size:]
self._msg_size = 0
self._msg_queue.put_nowait(data.decode())
else:
break
def pause_writing(self) -> None:
self._can_write.clear()
def resume_writing(self) -> None:
self._can_write.set()
async def _read(self) -> str:
if self._connection_closed and self._msg_queue.empty():
return None
return await self._msg_queue.get()
async def read_all(self) -> List[str]:
if self._msg_queue.empty():
if self._connection_closed:
return None
else:
return await self._msg_queue.get()
output = []
while not self._msg_queue.empty():
output.append(self._msg_queue.get_nowait())
return output
async def _write(self, data: Union[str, bytes]) -> None:
await self._can_write.wait()
if isinstance(data, str):
data = data.encode()
self.transport.write(struct.pack(">i", len(data)))
self.transport.write(data)
def _make_worker_message(
self,
state: Literal["config", "finish", "exec", "input"],
status: int, data: Union[list, dict]) -> dict:
return json.dumps({"state": state,
"status": status,
"data": data}).encode()
def delete_socket(self) -> None:
"""Метод для удаления сокета."""
try:
os.remove(self._socket_path)
self.transport.close()
self._connection_closed = True
except OSError:
pass
def kill(self) -> bool:
"""Метод для убийства демона."""
print("GONNA KILLA DAEMON:", self._pid)
if self.is_daemon_alive():
try:
os.kill(self._pid, signal.SIGKILL)
except OSError:
return False
else:
print("ALREADY DEAD")
return True
def stop(self) -> bool:
print("STOP DAEMON:", self._daemon_pid)
if self.is_daemon_alive():
try:
# Для остановки демона посылаем в него KeyboardInterrupt.
os.kill(self._pid, signal.SIGINT)
except OSError as error:
print(f"ERROR: Failed stopping daemon PID={self._pid}. "
f"Reason: {str(error)}")
return False
return True
def is_daemon_alive(self) -> bool:
try:
os.kill(self._pid, 0)
return True
except OSError:
return False

@ -0,0 +1,428 @@
import os
import sys
import json
import socket
import signal
import logging
from multiprocessing import Process
# from asyncio import sleep
# import logging
# import time
from io_module import IOModule
from daemon import daemon
from commands import Command
from typing import Dict, Optional, List, Union, Tuple, Literal
from asyncio.unix_events import _UnixSelectorEventLoop
from asyncio import get_event_loop, sleep
from pprint import pprint
LOG_LEVELS = {logging.ERROR: "ERROR",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.DEBUG: "DEBUG",
logging.CRITICAL: "CRITICAL",
}
class WorkerException(Exception):
pass
class UnexpectedWorkerFinish(WorkerException):
def __init__(self, reason: List[str], output: Optional[List[str]] = []):
"""Исключение кидаемое в случае, если воркер неожиданно умер."""
self.reason: List[str] = reason
self.output: List[dict] = output
class UnexpectedWorkerState(WorkerException):
"""Исключение кидаемое в случае, если состояние воркера не соответствует
ожидаемому."""
def __init__(self, state: str, expected: str):
self.state: str = state
self.expected: str = expected
class DaemonIsDead(WorkerException):
"""Исключение кидаемое в случае, если демон-воркер неожиданно оказался
мертв."""
def __init__(self, info: str, output: Optional[List[str]] = []):
self.info: str = info
self.output: List[dict] = output
class IncorrectConfiguration(WorkerException):
"""Исключение кидаемое при попытке запустить выполнение конфигурации, в
которой есть ошибки."""
def __init__(self, errors: Dict[str, str]):
self._errors = errors
class WorkersManager:
def __init__(self, loop: Optional[_UnixSelectorEventLoop] = None):
if loop is None:
self._loop = get_event_loop()
else:
self._loop = loop
self._configs: Dict[int, Worker] = {}
self._execs: Dict[int, Worker] = {}
self._wids: List[int] = []
async def make_worker(self, command: Command) -> Tuple[Union[int, None],
Union[str, None]]:
wid = self._get_wid()
try:
worker = Worker(wid, loop=self._loop)
await worker.create_daemon(command)
self._configs[wid] = worker
except Exception as error:
if wid in self._configs:
self._configs.pop(wid)
return None, str(error)
return wid, None
async def configure_worker(self, wid: int, parameters: List[dict],
reset: bool = False) -> Union[dict, None]:
"""Метод для модификации или сброса параметров конфигурации указанными
значениями."""
if not parameters:
return {}
if wid not in self._configs:
return None
worker: Worker = self._configs[wid]
try:
result = await worker.configure(parameters)
except (UnexpectedWorkerFinish, DaemonIsDead):
worker.kill()
self._configs.pop(wid)
raise
return result
async def cancel_worker_configuration(self, wid: int):
if wid not in self._configs:
return None
worker: Worker = self._configs[wid]
output = await worker.cancel_configuration()
self._configs.pop(wid)
return output
def clean(self):
"""Метод для убийства всех работающих на данный момент демонов."""
# TODO доработать его логику так, чтобы при перезапуске сервера этот
# метод никого не убивал.
print("KILLING WORKERS:")
for wid, worker in {**self._configs, **self._execs}.items():
result = False
try:
result = worker.kill()
finally:
if result:
print(f"[*] Worker {wid} is killed.")
else:
print(f"[x] Worker {wid} is not killed.")
continue
def get_config(self, wid: int):
if wid in self._configs:
return self._configs[wid]
return None
def get_execution(self, wid: int):
if wid in self._execs:
return self._execs[wid]
return None
def run_execution(self, wid: int) -> Tuple[int, Union[dict, None]]:
'''Метод для запуска исполнения команды, используя указанную
конфигурацию.
Вывод:
0, None -- исполнение успешно запущено;
1, None -- конфигурация с данным wid не найдена;
2, errors -- конфигурация с данным wid содержит ошибки;
3, None -- исполнение с данным wid уже существует;
'''
if wid not in self._configs:
return 1, None
if self._configs[wid].status > 0:
# Берем ошибки, как-то.
errors = {}
return 2, errors
if wid in self._execs:
return 3, None
worker = self._configs.pop(wid)
return 0, None
def _get_wid(self):
if not self._wids:
worker_id = 0
else:
worker_id = self._wids[-1] + 1
self._wids.append(worker_id)
return worker_id
def __repr__(self):
configs = ["Configurations:"]
configs.extend([f"wid={wid}" for wid in self._configs.keys()])
configs_repr = "\n\t".join(configs)
execs = ["Executions:"]
execs.extend([f"wid={wid}" for wid in self._execs.keys()])
execs_repr = "\n\t".join(execs)
return f"{configs_repr}\n{execs_repr}"
class Worker:
def __init__(self, wid: int,
loop: Optional[_UnixSelectorEventLoop] = None):
if loop is None:
self._loop = get_event_loop()
else:
self._loop = loop
self._wid: int = wid
self._socket_path: str = os.path.join(os.getcwd(),
f"worker_{self._wid}.sock")
if os.path.exists(self._socket_path):
os.remove(self._socket_path)
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._daemon_pid = None
# Контекст для чтения данных из сокета.
self._context: str = ""
self._state: str = ""
self._status: int = 0
@property
def state(self):
return self._state
@property
def status(self):
return self._status
async def configure(self, parameters: List[dict],
reset: bool = False) -> Union[dict, list]:
'''Метод для отправки в демона-воркера занчений параметров во время
конфигурации.'''
if self._state != "config":
raise UnexpectedWorkerState(self._state, "config")
message = self._make_worker_message("config", int(reset), parameters)
await self.send(message)
answers = await self.read()
print("WORKER'S ANSWERS")
pprint(answers)
config_answer = answers[0]
if config_answer["state"] == "finish":
raise UnexpectedWorkerFinish(config_answer["data"],
output=answers[1:])
return config_answer["data"]
async def cancel_configuration(self) -> List[dict]:
if self._state != "config":
raise UnexpectedWorkerState(self._state, "config")
stop_message = self._make_worker_message("finish", 1, None)
await self.send(stop_message)
output = await self.read()
print("OUTPUT")
pprint(output)
if output:
return output
return []
async def send(self, data: dict):
await self._loop.run_in_executor(None, self._send,
json.dumps(data) + '\0')
async def read(self):
messages = await self._loop.run_in_executor(None, self._read)
return messages
async def create_daemon(self, command: Command) -> None:
# Путь к временному файлу, в котором будет лежать pid демона-рабочего.
pid_file: str = os.path.join(os.getcwd(), f"PID_file_{self._wid}")
io = IOModule(self._socket_path, command.id)
runner_process = Process(target=daemonize_worker,
args=(io, self._wid, pid_file,
command)
)
runner_process.start()
self.sock.connect(self._socket_path)
runner_process.join()
self._daemon_pid = 0
while not self._daemon_pid:
await sleep(0.5)
self._daemon_pid = self._get_daemon_pid(pid_file)
self._delete_pid_file(pid_file)
print("DAEMON PID =", self._daemon_pid)
print("IS_ALIVE:", self.is_daemon_alive())
self._state = "config"
def kill(self) -> bool:
"""Метод для убийства демона."""
print("GONNA KILLA DAEMON:", self._daemon_pid)
if self.is_daemon_alive():
try:
os.kill(self._daemon_pid, signal.SIGKILL)
except OSError:
return False
else:
print("ALREADY DEAD")
# self._delete_socket()
self._daemon_pid = None
return True
def _stop_daemon(self) -> bool:
print("STOP DAEMON:", self._daemon_pid)
if self.is_daemon_alive():
try:
# Для остановки демона посылаем в него KeyboardInterrupt.
os.kill(self._daemon_pid, signal.SIGINT)
except OSError as error:
print(f"ERROR: Failed stopping daemon PID={self._daemon_pid}. "
f"Reason: {str(error)}")
return False
return True
def is_daemon_alive(self) -> bool:
try:
os.kill(self._daemon_pid, 0)
except OSError:
return False
else:
return True
def _send(self, data: str) -> bool:
try:
self.sock.sendall(data.encode())
except socket.error as error:
print(f"ERROR: can not send data to worker {self._wid}."
f" Reason: {str(error)}")
return False
return True
def _read(self) -> Union[dict, None]:
input_queue = []
while True:
try:
data = self.sock.recv(1024)
except Exception as error:
print(f"Connection is broken. Error: {str(error)}")
return None
raise DaemonIsDead(f"Connection is broken. "
f"Error: {str(error)}")
if data:
# Если данные получены делим их по нулевому байту.
data = data.decode().split('\0')
break
# Иначе считаем, что соединение нарушилось.
print("Connection is broken. Empty data.")
return None
raise DaemonIsDead("Connection is broken.")
if self._context:
self._context += data.pop(0)
# Если после взятия первого значения еще что-то есть в списке
# данных -- значит среди них был нулевой байт. И из значения
# контекста можно получить прочитанные данные.
if data:
request = json.loads(self._context)
input_queue.append(request)
self._context = ""
if data:
# Если данные еще есть в списке -- значит последнее значение в
# списке, даже если это пустая строка, является котекстом.
self._context = data.pop()
if data:
# Остальное добавляем в очередь.
for request in data:
input_queue.append(json.loads(request))
return input_queue
def _make_worker_message(self, state: Literal["config", "finish",
"exec", "input"],
status: int, data: Union[list, dict]) -> dict:
return {"state": state,
"status": status,
"data": data}
def _get_daemon_pid(self, pid_file_path: str) -> int:
if not os.path.exists(pid_file_path):
return 0
try:
with open(pid_file_path, "r") as pid_file:
daemon_pid = int(pid_file.readline())
return daemon_pid
except Exception:
return 0
def _delete_socket(self) -> None:
"""Метод для удаления сокета."""
try:
self.sock.close()
os.remove(self._socket_path)
except OSError:
pass
def _delete_pid_file(self, pid_file_path: str) -> None:
try:
os.remove(pid_file_path)
except OSError:
pass
def daemonize_worker(io: IOModule, wid: int, pid_file: str,
command: Command) -> None:
'''Функция запускаемая в отдельном процессе и порождающая демона при помощи
магии двойного форка.'''
base_dir = os.getcwd()
new_fork()
os.chdir("/")
os.setsid()
os.umask(0)
new_fork()
# Создаем файл, с помощью которого будем передавать pid демона-рабочего.
daemon_pid = str(os.getpid())
with open(pid_file, "w") as pid_file:
pid_file.write(daemon_pid)
daemon(io, wid, base_dir, command)
def new_fork() -> None:
'''Функция для создания форка текущего процесса.'''
try:
middle_pid = os.fork()
if middle_pid > 0:
sys.exit(0)
except OSError as error:
print(f"FORK ERROR: {error.errno} - {str(error)}")
sys.exit(1)

@ -1,56 +1,66 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from starlette.requests import Request
from ..utils.responses import (
ResponseStructure,
validate_response,
get_base_url,
get_command_not_found,
get_cl_command_not_found,
)
from typing import Optional
from ..utils.dependencies import right_checkers from ..utils.dependencies import get_current_user
from ..utils.users import check_user_rights
from ..server_data import ServerData from ..server_data import ServerData
from ..schemas.users import User
from ..schemas.responses import (
GetRootResponse,
GetCommandsResponse,
FoundCommandInfo,
GetCommandParametersResponse,
)
data = ServerData() data = ServerData()
router = APIRouter() router = APIRouter()
@router.get("/commands", tags=["Commands management"], @router.get("/commands", response_model=GetCommandsResponse, tags=["Commands"])
dependencies=[Depends(right_checkers["read"])]) async def get_available_commands(request: Request,
async def get_commands() -> dict: gui: Optional[bool] = False):
'''Обработчик, отвечающий на запросы списка команд.''' response_data = commands.get_commands(get_base_url(request))
response = {} return validate_response(response_data, GetCommandsResponse,
for command_id, command_object in data.commands.items(): media_type="application/hal+json")
response.update({command_id: {"title": command_object.title,
"category": command_object.category,
"icon": command_object.icon, @router.get("/commands/{command}",
"command": command_object.command}}) response_model=FoundCommandInfo,
return response tags=["Commands"])
async def find_command_data(command: str, request: Request,
gui: Optional[bool] = False,
@router.get("/commands/{cid}", tags=["Commands management"], by_id: Optional[bool] = False):
dependencies=[Depends(right_checkers["read"])]) base_url = get_base_url(request)
async def get_command(cid: int) -> dict:
'''Обработчик запросов списка команд.''' if by_id:
if cid not in data.commands_instances: command_data = commands.get_by_id(command, base_url)
# TODO добавить какую-то обработку ошибки. if command_data is None:
pass raise get_command_not_found(command)
return {'id': cid, else:
'name': f'command_{cid}'} command_data = commands.find_command(command, base_url)
if command_data is None:
raise get_cl_command_not_found(command)
@router.get("/commands/{cid}/groups", tags=["Commands management"],
dependencies=[Depends(right_checkers["read"])]) return validate_response(command_data, FoundCommandInfo,
async def get_command_parameters_groups(cid: int) -> dict: media_type="application/hal+json")
'''Обработчик запросов на получение групп параметров указанной команды.'''
pass
@router.get("/commands/{command_id}/parameters",
response_model=GetCommandParametersResponse,
@router.get("/commands/{cid}/parameters", tags=["Commands management"], tags=["Commands"])
dependencies=[Depends(right_checkers["read"])]) async def get_command_parameters(command_id: str, request: Request):
async def get_command_parameters(cid: int) -> dict: parameters_data = commands.get_parameters(command_id,
'''Обработчик запросов на получение параметров указанной команды.''' get_base_url(request))
pass if parameters_data is None:
raise get_command_not_found(command_id)
return parameters_data
@router.post("/commands/{command_id}", tags=["Commands management"],
dependencies=[Depends(right_checkers["write"])])
async def post_command(command_id: str) -> int:
if command_id not in data.commands:
# TODO добавить какую-то обработку ошибки.
pass
return

@ -0,0 +1,22 @@
from typing import Any, List
from pydantic import BaseModel
# PATCH /configs/{WID}/parameters
class ParameterValue(BaseModel):
id: str
value: Any
class ModifyConfigRequest(BaseModel):
__root__: List[ParameterValue]
# PUT /configs/{WID}/parameters
class ResetConfigRequest(BaseModel):
__root__: List[ParameterValue]
# PATCH /executions/{WID}/input
class WriteToExecutionRequest(BaseModel):
data: Any

@ -0,0 +1,180 @@
from typing import Any, List, Dict, Optional, Literal, Union
from pydantic import BaseModel, Field, HttpUrl
class NotTemplatedLinkData(BaseModel):
href: HttpUrl = "URI"
class Config:
extra = "forbid"
class TemplatedLinkData(NotTemplatedLinkData):
templated: bool = False
LinkData = Union[NotTemplatedLinkData, TemplatedLinkData]
# GET /
class ServerInfo(BaseModel):
name: str = "Server name"
version: str = "Server version"
class GetRootResponse(BaseModel):
data: ServerInfo
links: Dict[str, LinkData] = Field(..., alias="_links",
description="Links to resources")
# GET /commands/{CID}/parameters
class ParameterInfo(BaseModel):
id: str
default: Any
class ParametersGroup(BaseModel):
group_id: str
parameters: List[ParameterInfo]
class GetCommandParametersResponse(BaseModel):
data: List[ParametersGroup]
links: Dict[str, LinkData] = Field(..., alias="_links")
# GET /commands/{CID}
class CommandData(BaseModel):
id: str
title: str
category: str
command: str
class CommandInfo(BaseModel):
data: CommandData
links: Dict[str, LinkData] = Field(..., alias="_links")
class FoundCommandInfo(CommandInfo):
embedded: Dict[str,
GetCommandParametersResponse] = Field(...,
alias="_embedded")
# GET /commands/
class GetCommandsResponse(BaseModel):
__root__: Dict[str, CommandInfo]
# class Config:
# @staticmethod
# def schema_extra(schema, model):
# example = {}
# for num in range(2):
# resp = ResponseStructure()
# resp.add_data(id=f"command_{num}",
# command=f"cl-command-{num}",
# title=f"Command {num}",
# category="cool-category"
# )
# resp.add_link("parameters",
# f"/commands/command_{num}/parameters")
# resp.add_link("configure", f"/configs/command_{num}")
# example[f"command_{num}"] = resp.get_dict()
# schema["example"] = example
# POST /configs/{command}
class CreateConfigResponse(BaseModel):
links: Dict[str, LinkData] = Field(..., alias="_links")
# PATCH /configs/{WID}/parameters
class ModifyConfigCorrectResponse(BaseModel):
links: Dict[str, LinkData] = Field(..., alias="_links")
class Config:
extra = "forbid"
class ModifyConfigUncorrectResponse(ModifyConfigCorrectResponse):
data: Optional[Dict[str, str]]
links: Dict[str, LinkData] = Field(..., alias="_links")
ModifyConfigResponse = Union[ModifyConfigCorrectResponse,
ModifyConfigUncorrectResponse]
# PUT /configs/{WID}/parameters
class ResetConfigResponse(BaseModel):
data: Optional[Dict[str, str]]
links: Dict[str, LinkData] = Field(..., alias="_links")
# POST /executions/{WID}
class CreateExecutionResponse(BaseModel):
links: Dict[str, LinkData] = Field(..., alias="_links")
# DELETE /executions/{WID}
class OutputData(BaseModel):
type: Literal["output"]
logging: Union[int, None]
text: str
source: str
class OutputMessage(BaseModel):
data: OutputData
class StopExecutionsResponse(BaseModel):
__root__: List[OutputMessage]
# DELETE /configs/{WID}
class StopConfigResponse(BaseModel):
__root__: List[OutputMessage]
# GET /executions/{WID}/output
class InputRequestData(BaseModel):
type: Literal["input"]
text: str
source: str
class InputMessage(BaseModel):
data: InputRequestData
links: Dict[str, LinkData] = Field(..., alias="_links")
class GetExecutionOutputResponse(BaseModel):
__root__: List[Union[OutputMessage, InputMessage]]
# GET /workers/
class WorkerData(BaseModel):
socket: str
command: str
pid: int
class WorkerInfo(BaseModel):
data: WorkerData
links: Dict[str, LinkData] = Field(..., alias="_links")
class GetWorkersResponse(BaseModel):
__root__: Dict[int, WorkerInfo]
# DELETE /workers/{WID}
# Сообщения протокола взаимодействия сервера и воркеров.
# class WorkerMessageBase(BaseModel):
# state: str
# status: int

@ -1,15 +1,33 @@
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends
from starlette.requests import Request
from .server_data import ServerData from .server_data import ServerData
from .utils.dependencies import right_checkers from .database.db import database
from .models.database import database
# from .utils.dependencies import right_checkers, get_current_user
from .utils.dependencies import get_current_user
from .utils.responses import (
ResponseStructure,
get_base_url,
validate_response,
)
from .utils.users import check_user_rights
from .schemas.responses import GetRootResponse
from .schemas.users import User
from .routers.users import router as users_router from .routers.users import router as users_router
from .routers.commands import router as commands_router from .routers.commands import router as commands_router
# TODO # TODO
# 1. Разобраться с описанием команд как ресурсов и со всем, что от них зависит. # 1. Добавить список отклоняемых токенов и их обработку.
# 2. Разобраться с объектами воркеров. И способом их функционирования. # 2. Добавить предварительный вариант генерации access_tokens.
# 3. Продумать, наконец, концепцию cid и wid, в ключе их применения при
# настройке команд и запуске воркеров.
# 4. Решить сколько команд может запускать пользователь единовременно.
# 5. Добавить способ указания принадлежности воркеров и команд пользователям.
# 6. Разобраться с местом раcположения базы данных.
data = ServerData() data = ServerData()
@ -26,30 +44,39 @@ async def shutdown():
await database.disconnect() await database.disconnect()
@app.get("/", tags=["Root"], @app.get("/", response_model=GetRootResponse, tags=["Home"])
dependencies=[Depends(right_checkers["read"])]) async def get_primary_commands(request: Request,
async def get_root() -> dict: user: User = Depends(get_current_user)):
'''Обработчик корневых запросов.''' check_user_rights(user, "read")
return {'msg': 'root msg'}
resp = ResponseStructure(get_base_url(request))
resp.add_data(name="Calculate Server", version="0.1")
resp.add_link("commands", "/commands?gui={is_gui}", templated=True)
resp.add_link("find_command", "/commands/{console_command}?gui={is_gui}",
templated=True)
resp.add_link("workers", "/workers")
return validate_response(resp.get_dict(), GetRootResponse,
media_type="application/hal+json")
@app.get("/workers/{wid}", tags=["Workers management"], # @app.get("/workers/{wid}", tags=["Workers management"],
dependencies=[Depends(right_checkers["write"])]) # dependencies=[Depends(right_checkers["write"])])
async def get_worker(wid: int): # async def get_worker(wid: int):
'''Тестовый обработчик.''' # '''Тестовый обработчик.'''
worker = data.get_worker_object(wid=wid) # worker = data.get_worker_object(wid=wid)
worker.run() # worker.run()
print(f"worker: {type(worker)} object {worker}") # print(f"worker: {type(worker)} object {worker}")
await worker.send({"text": "INFO"}) # await worker.send({"text": "INFO"})
worker_data = await worker.get() # worker_data = await worker.get()
if worker_data['type'] == 'log': # if worker_data['type'] == 'log':
data.log_message[data['level']](data['msg']) # data.log_message[data['level']](data['msg'])
return worker_data # return worker_data
# Authentification and users management. # Authentification and users management.
app.include_router(users_router) app.include_router(users_router)
# Commands creation and management. # Commands running and management.
app.include_router(commands_router) app.include_router(commands_router)

@ -1,20 +1,25 @@
import os import os
import asyncio import asyncio
import importlib import importlib
from typing import Dict, Optional from typing import Dict, Optional, List, Union
from logging.config import dictConfig from logging.config import dictConfig
from logging import getLogger, Logger from logging import getLogger, Logger
from ..variables.loader import Datavars from ..variables.loader import Datavars
from ..commands.commands import Command, CommandRunner from ..commands.commands import Command, CommandRunner
from .utils.workers import Worker from .utils.responses import ResponseStructure
# from .utils.workers import Worker
from .schemas.config import ConfigSchema from .schemas.config import ConfigSchema
from calculate.utils.tools import Singleton
# Получаем конфигурацию сервера. # Получаем конфигурацию сервера.
TESTING = bool(os.environ.get("TESTING", False)) TESTING = bool(os.environ.get("TESTING", False))
if not TESTING: if not TESTING:
from .config import config from .config import config
server_config = ConfigSchema(**config) server_config = ConfigSchema(**config)
@ -23,7 +28,7 @@ else:
server_config = ConfigSchema(**config) server_config = ConfigSchema(**config)
class ServerData: class ServerData(metaclass=Singleton):
def __init__(self, config: ConfigSchema = server_config): def __init__(self, config: ConfigSchema = server_config):
self.event_loop = asyncio.get_event_loop() self.event_loop = asyncio.get_event_loop()
@ -48,7 +53,8 @@ class ServerData:
self.commands_runners: Dict[str, CommandRunner] = {} self.commands_runners: Dict[str, CommandRunner] = {}
# Словарь WID и экземпляров процессов-воркеров, передаваемых воркерам. # Словарь WID и экземпляров процессов-воркеров, передаваемых воркерам.
self.workers: Dict[int, Worker] = {} # TODO добавить менеджера воркеров.
# self.workers: Dict[int, Worker] = {}
@property @property
def datavars(self): def datavars(self):
@ -87,18 +93,105 @@ class ServerData:
def make_command(self, command_id: str, ) -> int: def make_command(self, command_id: str, ) -> int:
'''Метод для создания команды по ее описанию.''' '''Метод для создания команды по ее описанию.'''
command_description = self.commands[command_id] # command_description = self.commands[command_id]
def _get_worker_object(self, wid: Optional[int] = None) -> Worker: # def _get_worker_object(self, wid: Optional[int] = None) -> Worker:
'''Метод для получения воркера для команды.''' # '''Метод для получения воркера для команды.'''
if wid is not None: # if wid is not None:
worker = Worker(wid, self._event_loop, self._datavars) # worker = Worker(wid, self._event_loop, self._datavars)
self._workers[wid] = worker # self._workers[wid] = worker
elif not self._workers: # elif not self._workers:
worker = Worker(0, self._event_loop, self._datavars) # worker = Worker(0, self._event_loop, self._datavars)
self._workers[0] = worker # self._workers[0] = worker
# else:
# wid = max(self._workers.keys()) + 1
# worker = Worker(wid, self._event_loop, self._datavars)
# self._workers[wid] = worker
# return worker
class Commands:
'''Предварительная реализация контейнера описаний команд.'''
def __init__(self, *commands: List[Command]):
self._by_id: dict = dict()
self._by_command: dict = dict()
for command in commands:
self._by_id[command.id] = command
self._by_command[command.command] = command
def get_commands(self, base_url: str) -> Dict[str, dict]:
response = dict()
for command in self._by_id.values():
data = ResponseStructure(base_url)
data.add_data(id=command.id,
title=command.title,
category=command.category,
command=command.command)
data.add_link("self", f"/commands/{command.id}?by_id=true")
data.add_link("parameters", f"/commands/{command.id}/parameters")
data.add_link("configure", f"/configs/{command.id}")
response[command.id] = data.get_dict()
return response
def get_by_id(self, command_id: str, base_url: str) -> Dict[str, Dict]:
if command_id in self._by_id:
command = self._by_id[command_id]
data = ResponseStructure(base_url)
data.add_data(id=command.id,
title=command.title,
category=command.category,
command=command.command)
data.add_link("self", f"/commands/{command.id}?by_id=1")
data.add_link("parameters", f"/commands/{command.id}/parameters")
data.add_link("configure", f"/configs/{command.id}")
parameters_data = self._get_parameters_data(command.parameters,
command.id, base_url)
data.embed("parameters", parameters_data)
return data.get_dict()
return None
def find_command(self, console_command: str,
base_url: str) -> Union[dict, None]:
if console_command in self._by_command:
command = self._by_command[console_command]
data = ResponseStructure(base_url)
data.add_data(id=command.id,
title=command.title,
category=command.category,
command=command.command)
data.add_link("self", f"/commands/{command.id}?by_id=1")
data.add_link("parameters", f"/commands/{command.id}/parameters")
data.add_link("configure", f"/configs/{command.id}")
parameters_data = self._get_parameters_data(command.parameters,
command.id, base_url)
data.embed("parameters", parameters_data)
return data.get_dict()
else:
return None
def get_parameters(self, command_id: str, base_url: str
) -> Union[dict, None]:
if command_id in self._by_id:
command = self._by_id[command_id]
parameters_data = self._get_parameters_data(command.parameters,
command.id, base_url)
return parameters_data
else: else:
wid = max(self._workers.keys()) + 1 return None
worker = Worker(wid, self._event_loop, self._datavars)
self._workers[wid] = worker def get_command_object(self, command_id: str) -> Command:
return worker if command_id in self._by_id:
return self._by_id[command_id]
return None
def _get_parameters_data(self, parameters: list, command_id: str,
base_url: str) -> List[dict]:
data = ResponseStructure(base_url)
data.add_data(parameters)
data.add_link("self", f"/commands/{command_id}/parameters")
return data.get_dict()

@ -14,7 +14,7 @@ from .users import get_user_by_username
from calculate.utils.files import Process from calculate.utils.files import Process
def make_secret_key(): def make_secret_key() -> str:
openssl_process = Process("/usr/bin/openssl", "rand", "-hex", "32") openssl_process = Process("/usr/bin/openssl", "rand", "-hex", "32")
secret_key = openssl_process.read() secret_key = openssl_process.read()
return secret_key.strip() return secret_key.strip()

@ -0,0 +1,5 @@
from multiprocessing.connection import Listener
def daemon(listener: Listener, wid: int, base_dir: str, command):
pass

@ -0,0 +1,122 @@
from fastapi import HTTPException, status
from fastapi.encoders import jsonable_encoder
from starlette.responses import JSONResponse
from starlette.requests import Request
from pydantic.main import ModelMetaclass
from pydantic import parse_obj_as
from typing import Dict, Union, _GenericAlias, Optional
class ResponseStructure:
'''Класс структуры данных, для получения HATEOAS ответа на запросы
клиента.'''
def __init__(self, base_url: str):
self._base_url: str = base_url.strip("/")
self._data: dict = dict()
self._links: Dict[str, Dict[str, str]] = dict()
self._embedded: Dict[str, dict] = dict()
def get_dict(self) -> dict:
dictionary = dict()
if self._data:
dictionary["data"] = self._data
if self._links:
dictionary["_links"] = self._links
if self._embedded:
dictionary["_embedded"] = self._embedded
return dictionary
def add_data(self, *args, **kwargs) -> "ResponseStructure":
if args:
if isinstance(self._data, dict):
if len(args) > 1:
self._data = list(args)
else:
self._data = args[0]
elif isinstance(self._data, list):
if len(args) > 1:
self._data.extend(args)
else:
self._data.append(args[0])
else:
self._data = [self._data, *args]
elif kwargs:
if not isinstance(self._data, dict):
self._data = {}
self._data.update(kwargs)
return self
def embed(self, resource: str,
content: Union[dict, list]) -> "ResponseStructure":
self._embedded[resource] = content
return self
def add_link(self, action: str, uri: str,
templated: bool = False) -> "ResponseStructure":
uri = self._get_uri(uri)
if action in self._links:
link_section = self._links[action]
if link_section["href"] != uri:
link_section["href"] = uri
else:
link_section = {"href": uri}
self._links[action] = link_section
if templated:
print("TEMPLATED:", action)
link_section["templated"] = templated
return self
def _get_uri(self, uri: str) -> str:
if uri.startswith(self._base_url):
return uri
return f"{self._base_url}/{uri.strip('/')}"
def get_base_url(request: Request) -> str:
base_url = f"{request.base_url.scheme}://{request.base_url.netloc}"
return base_url
def get_command_not_found(command_id: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=(f'Command with id "{command_id}"'
' is not found.'))
def get_cl_command_not_found(console_command: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=(f'Console command "{console_command}" is not'
' found.'))
def get_configuration_not_found(wid: int) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=f"Configuration with id={wid} is not found.")
def validate_response(data: Union[dict, list],
schema: Union[ModelMetaclass, _GenericAlias, type],
media_type: Optional[str] = None,
status_code: int = 200
) -> JSONResponse:
"""Функция для валидации данных ответа сервера по указанной схеме с учетом
наличия псевдонимов полей и возможности того, что схема задана обычным
типом, типом из typing или BaseModel с __root__. Возвращает объект ответа.
"""
if isinstance(schema, ModelMetaclass):
if "__root__" in schema.__annotations__:
validated = schema(__root__=data)
else:
validated = schema(**data)
elif isinstance(schema, (_GenericAlias, type)):
validated = parse_obj_as(schema, data)
return JSONResponse(content=jsonable_encoder(validated, by_alias=True),
media_type=media_type, status_code=status_code)

@ -1,30 +1,31 @@
from fastapi import HTTPException, status
from sqlalchemy import func, and_ from sqlalchemy import func, and_
from typing import List from typing import List
from ..models.database import database from ..database.db import database
from ..models.users import users_table, users_rights, rights_table from ..database.users import users_table as ut, users_rights, rights_table
from ..schemas.users import UserCreate from ..schemas.users import UserCreate, User
async def get_user_by_username(username: str): async def get_user_by_username(username: str):
'''Метод для получения строки с данными пользователя из базы данных по '''Метод для получения строки с данными пользователя из базы данных по
username.''' username.'''
query = users_table.select().where(users_table.c.login == username) query = ut.select().where(ut.c.login == username)
user_data = await database.fetch_one(query) user_data = await database.fetch_one(query)
query = (users_table. query = (ut.
join(users_rights). join(users_rights).
join(rights_table). join(rights_table).
select(). select().
where(and_(users_table.c.id == users_rights.c.user_id, where(and_(ut.c.id == users_rights.c.user_id,
users_table.c.login == username, ut.c.login == username,
rights_table.c.id == users_rights.c.right_id)). rights_table.c.id == users_rights.c.right_id)).
with_only_columns([users_table.c.id, with_only_columns([ut.c.id,
users_table.c.login, ut.c.login,
users_table.c.password, ut.c.password,
func.group_concat(rights_table.c.name, func.group_concat(rights_table.c.name,
' ').label("rights")]). ' ').label("rights")]).
group_by(users_table.c.id)) group_by(ut.c.id))
response = await database.fetch_one(query) response = await database.fetch_one(query)
user_data = dict(response) user_data = dict(response)
@ -33,6 +34,16 @@ async def get_user_by_username(username: str):
async def create_user(username: str, hashed_password: str, rights: List[str]): async def create_user(username: str, hashed_password: str, rights: List[str]):
user = UserCreate(login=username, UserCreate(login=username,
password=hashed_password, password=hashed_password,
rights=rights) rights=rights)
def check_user_rights(user_data: User, *rights: List[str]) -> None:
user_rights = user_data.rights
for right in user_rights:
if right not in user_rights:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
detail='Not enough permissions',
headers={"WWW-Authenticate": "Bearer"}
)

@ -1,12 +1,415 @@
import os import os
import sys
import json import json
import socket import struct
import signal
import asyncio
import logging import logging
from typing import Union from multiprocessing import Process
from calculate.variables.loader import Datavars from multiprocessing.connection import Listener, Connection
from multiprocessing import Queue, Process # import logging
from calculate.commands.commands import CommandRunner, Command # import time
# from time import sleep from .daemon import daemon
# from commands import Command
from typing import Dict, Optional, List, Union, Tuple, Literal
from asyncio.unix_events import _UnixSelectorEventLoop
from asyncio import get_event_loop
LOG_LEVELS = {logging.ERROR: "ERROR",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.DEBUG: "DEBUG",
logging.CRITICAL: "CRITICAL",
}
class WorkerException(Exception):
pass
class UnexpectedWorkerFinish(WorkerException):
def __init__(self, reason: List[str], output: Optional[List[str]] = []):
"""Исключение кидаемое в случае, если воркер неожиданно умер."""
self.reason: List[str] = reason
self.output: List[dict] = output
class UnexpectedWorkerState(WorkerException):
"""Исключение кидаемое в случае, если состояние воркера не соответствует
ожидаемому."""
def __init__(self, state: str, expected: str):
self.state: str = state
self.expected: str = expected
class DaemonIsDead(WorkerException):
"""Исключение кидаемое в случае, если демон-воркер неожиданно оказался
мертв."""
def __init__(self, info: str, output: Optional[List[str]] = []):
self.info: str = info
self.output: List[dict] = output
class IncorrectConfiguration(WorkerException):
"""Исключение кидаемое при попытке запустить выполнение конфигурации, в
которой есть ошибки."""
def __init__(self, errors: Dict[str, str]):
self._errors = errors
class WorkersManager:
def __init__(self, loop: Optional[_UnixSelectorEventLoop] = None):
if loop is None:
self._loop = get_event_loop()
else:
self._loop = loop
self._configs: Dict[int, WorkerProtocol] = {}
self._execs: Dict[int, WorkerProtocol] = {}
self._wids: List[int] = []
async def make_worker(self, command) -> Tuple[Union[int, None],
Union[str, None]]:
"""Метод для создания воркеров."""
wid = self._get_wid()
try:
# Создаем воркера и получаем интерфейс для взаимодействия с ним.
worker_api = await self._create_worker(wid, command)
self._configs[wid] = worker_api
except Exception as error:
if wid in self._configs:
self._configs.pop(wid)
return None, str(error)
return wid, None
async def configure_worker(self, wid: int, parameters: List[dict],
reset: bool = False) -> Union[dict, None]:
"""Метод для модификации или сброса параметров конфигурации указанными
значениями.
"""
if not parameters:
return {}
if wid not in self._configs:
return None
worker_api: WorkerProtocol = self._configs[wid]
try:
result = await worker_api.configure(parameters)
except (UnexpectedWorkerFinish, DaemonIsDead):
worker_api.kill()
self._configs.pop(wid)
raise
return result
async def cancel_worker_configuration(self, wid: int):
"""Метод для завершения конфигурации воркера и завершения его работы.
"""
if wid not in self._configs:
return None
worker_api: WorkerProtocol = self._configs[wid]
output = await worker_api.cancel_configuration()
worker_api.delete_socket()
self._configs.pop(wid)
return output
def clean(self):
"""Метод для убийства всех работающих на данный момент демонов."""
# TODO доработать его логику так, чтобы при перезапуске сервера этот
# метод никого не убивал.
for wid, worker in {**self._configs, **self._execs}.items():
result = False
try:
result = worker.kill()
finally:
if result:
print(f"[*] Worker {wid} is killed.")
else:
print(f"[x] Worker {wid} is not killed.")
continue
def get_config(self, wid: int):
if wid in self._configs:
return self._configs[wid]
return None
def get_execution(self, wid: int):
if wid in self._execs:
return self._execs[wid]
return None
def run_execution(self, wid: int) -> Tuple[int, Union[dict, None]]:
'''Метод для запуска исполнения команды, используя указанную
конфигурацию.
Вывод:
0, None -- исполнение успешно запущено;
1, None -- конфигурация с данным wid не найдена;
2, errors -- конфигурация с данным wid содержит ошибки;
3, None -- исполнение с данным wid уже существует;
'''
if wid not in self._configs:
return 1, None
if self._configs[wid].status > 0:
# Берем ошибки, как-то.
errors = {}
return 2, errors
if wid in self._execs:
return 3, None
# worker = self._configs.pop(wid)
return 0, None
async def _create_worker(self, wid: int, command) -> "WorkerProtocol":
# Путь к временному файлу, в котором будет лежать pid демона-рабочего.
pid_file_path: str = os.path.join(os.getcwd(), f"pid_file_{wid}")
socket_path: str = os.path.join(os.getcwd(),
f"worker_{wid}.sock")
if os.path.exists(socket_path):
os.remove(socket_path)
process_runner = Process(target=daemonize_worker,
args=(socket_path, wid, pid_file_path,
command)
)
process_runner.start()
process_runner.join()
daemon_pid = await self._get_daemon_pid(pid_file_path)
self._delete_pid_file(pid_file_path)
transport, worker_interface = await self._loop.create_unix_connection(
lambda: WorkerProtocol(daemon_pid,
socket_path),
socket_path)
return worker_interface
def _get_wid(self):
if not self._wids:
worker_id = 0
else:
worker_id = self._wids[-1] + 1
self._wids.append(worker_id)
return worker_id
async def _get_daemon_pid(self, pid_file_path: str) -> int:
while True:
if not os.path.exists(pid_file_path):
# Если файла нет -- уступаем работу другим воркерам.
await asyncio.sleep(0)
with open(pid_file_path, "r") as pid_file:
daemon_pid = int(pid_file.readline())
return daemon_pid
def _delete_pid_file(self, pid_file_path: str) -> None:
try:
os.remove(pid_file_path)
except OSError:
pass
def __repr__(self):
configs = ["Configurations:"]
configs.extend([f"wid={wid}" for wid in self._configs.keys()])
configs_repr = "\n\t".join(configs)
execs = ["Executions:"]
execs.extend([f"wid={wid}" for wid in self._execs.keys()])
execs_repr = "\n\t".join(execs)
return f"{configs_repr}\n{execs_repr}"
def daemonize_worker(socket_path: str, wid: int, pid_file: str,
command) -> None:
'''Функция запускаемая в отдельном процессе и порождающая демона при помощи
магии двойного форка.'''
base_dir = os.getcwd()
new_fork()
os.chdir("/")
os.setsid()
os.umask(0)
new_fork()
# Создаем файл, с помощью которого будем передавать pid демона-рабочего.
daemon_pid = str(os.getpid())
with open(pid_file, "w") as pid_file:
pid_file.write(daemon_pid)
with Listener(socket_path) as listener:
daemon(listener, wid, base_dir, command)
def new_fork() -> None:
'''Функция для создания форка текущего процесса.'''
try:
middle_pid = os.fork()
if middle_pid > 0:
sys.exit(0)
except OSError as error:
print(f"FORK ERROR: {error.errno} - {str(error)}")
sys.exit(1)
class WorkerProtocol(asyncio.Protocol):
"""Класс протокола управления воркером."""
def __init__(self, pid: int, socket_path: str):
self.loop = asyncio.get_running_loop()
self._pid: int = pid
self._socket_path: str = socket_path
# Объект Event, предназначенный для остановки записи при переполнении
# буффера записи в трспорте.
self._can_write = asyncio.Event()
self._can_write.clear()
self._msg_queue = asyncio.Queue()
# Контекст чтения данных воркера.
self._buffer = bytearray()
self._msg_size: int = 0
self._connection_closed: bool = True
async def configure(self, parameters: List[dict],
reset: bool = False) -> list:
"""Метод для конфигурации воркера указанными значениями параметров.
Возвращает список ошибок, если таковые есть, или пустой список, если
никаких ошибок нет.
"""
message = self._make_worker_message("config", int(reset), parameters)
await self._write(message)
response = await self._read()
response = json.loads(response)
return response["data"]
async def cancel_configuration(self) -> List[dict]:
"""Метод для остановки конфигурации воркера и его работы в целом."""
stop_message: bytes = self._make_worker_message("finish", 1, None)
await self._write(stop_message)
response = await self._read()
response = json.loads(response)
return response["data"]
def connection_made(self, transport: asyncio.Transport) -> None:
self.transport = transport
self._can_write.set()
self._connection_closed: bool = False
print("Connection made.")
def connection_lost(self, exc: BaseException) -> None:
if exc:
print("Connection lost. Error:", str(exc))
else:
print("Connection lost.")
self._connection_closed: bool = True
def data_received(self, data: bytes) -> None:
self._buffer.extend(data)
while True:
if not self._msg_size:
if len(self._buffer) < 4:
return
size_length = struct.calcsize(">i")
self._msg_size, = struct.unpack(">i",
self._buffer[:size_length])
self._buffer = self._buffer[size_length:]
if len(self._buffer) >= self._msg_size:
data = self._buffer[:self._msg_size]
self._buffer = self._buffer[self._msg_size:]
self._msg_size = 0
self._msg_queue.put_nowait(data.decode())
else:
break
def pause_writing(self) -> None:
self._can_write.clear()
def resume_writing(self) -> None:
self._can_write.set()
async def _read(self) -> str:
if self._connection_closed and self._msg_queue.empty():
return None
return await self._msg_queue.get()
async def read_all(self) -> List[str]:
if self._msg_queue.empty():
if self._connection_closed:
return None
else:
return await self._msg_queue.get()
output = []
while not self._msg_queue.empty():
output.append(self._msg_queue.get_nowait())
return output
async def _write(self, data: Union[str, bytes]) -> None:
await self._can_write.wait()
if isinstance(data, str):
data = data.encode()
self.transport.write(struct.pack(">i", len(data)))
self.transport.write(data)
def _make_worker_message(
self,
state: Literal["config", "finish", "exec", "input"],
status: int, data: Union[list, dict]) -> dict:
return json.dumps({"state": state,
"status": status,
"data": data}).encode()
def delete_socket(self) -> None:
"""Метод для удаления сокета."""
try:
os.remove(self._socket_path)
self.transport.close()
self._connection_closed = True
except OSError:
pass
def kill(self) -> bool:
"""Метод для убийства демона."""
print("GONNA KILLA DAEMON:", self._pid)
if self.is_daemon_alive():
try:
os.kill(self._pid, signal.SIGKILL)
except OSError:
return False
else:
print("ALREADY DEAD")
return True
def stop(self) -> bool:
print("STOP DAEMON:", self._daemon_pid)
if self.is_daemon_alive():
try:
# Для остановки демона посылаем в него KeyboardInterrupt.
os.kill(self._pid, signal.SIGINT)
except OSError as error:
print(f"ERROR: Failed stopping daemon PID={self._pid}. "
f"Reason: {str(error)}")
return False
return True
def is_daemon_alive(self) -> bool:
try:
os.kill(self._pid, 0)
return True
except OSError:
return False
class WorkerIOError(KeyboardInterrupt): class WorkerIOError(KeyboardInterrupt):
@ -15,24 +418,24 @@ class WorkerIOError(KeyboardInterrupt):
class IOModule: class IOModule:
'''Класс модуля ввода/вывода для воркеров.''' '''Класс модуля ввода/вывода для воркеров.'''
def __init__(self, socket_path: str): def __init__(self, listener: Listener, command_name: str):
self._sock = socket.socket(socket.AF_UNIX, self._listener: Listener = listener
socket.SOCK_STREAM) self._connection: Union[Connection, None] = None
self._sock.bind(socket_path)
self._sock.listen()
self._connection = None
def input(self, msg: str) -> str: self._command: str = command_name
'''Метод через который возможен ввод данных в скрипт.''' self._script: str = ""
input_request = json.dumps({"type": "input",
"msg": msg}) + '\0' @property
answer = None def command(self) -> str:
while answer is None: return self._command
if not self._check_connection(self._connection):
self._connection = self._make_connection() @property
self._connection.sendall(input_request.encode()) def script(self) -> str:
answer = self._get() return self._script
return answer['data']
@script.setter
def script(self, script: str) -> None:
self._script = script
def set_debug(self, msg: str) -> None: def set_debug(self, msg: str) -> None:
self.output(msg, level=logging.DEBUG) self.output(msg, level=logging.DEBUG)
@ -49,142 +452,57 @@ class IOModule:
def set_critical(self, msg: str) -> None: def set_critical(self, msg: str) -> None:
self.output(msg, level=logging.CRITICAL) self.output(msg, level=logging.CRITICAL)
def output(self, msg: str, level: int = logging.INFO) -> None: def output(self, text: str, level: int = logging.INFO) -> None:
'''Метод для отправки серверу вывода с указанием уровня.''' '''Метод для отправки серверу вывода с указанием уровня.'''
if not self._check_connection(self._connection): output_request = {"state": "exec",
self._connection = self._make_connection() "status": 0,
output_request = json.dumps({"type": "output", "data": {
"level": level, "type": "message",
"msg": msg}) + '\0' "logging": level,
self._connection.sendall(output_request.encode()) "text": text,
"source": self._script
}
}
self.send(output_request)
def input(self, msg: str) -> str:
'''Метод через который возможен ввод данных в скрипт.'''
input_request = {"state": "input",
"status": 0,
"data": {"text": msg,
"source": f"{self.command}:{self.script}"}}
answer = None
while answer is None:
self.send(input_request)
answer = self.receive()
return answer['data']
def send(self, data: dict) -> None: def send(self, data: dict) -> None:
'''Метод для отправки данных серверу.''' '''Метод для отправки данных серверу.'''
if not self._check_connection(self._connection): if self._connection is None:
self._connection = self._make_connection() raise WorkerIOError("No one is connected now.")
data = json.dumps(data) + '\0' data = json.dumps(data).encode()
self._connection.sendall(data.encode()) self._connection.send_bytes(data)
def receive(self) -> dict: def receive(self) -> dict:
'''Метод для получения данных от сервера.''' '''Метод для получения данных от сервера.'''
data = None if self._connection is None:
while data is None: raise WorkerIOError("No one is connected now.")
if not self._check_connection(self._connection): data: bytes = self._connection.recv_bytes()
self._connection = self._make_connection() return json.loads(data.decode())
data = self._get()
return data
def _get(self) -> Union[None, dict]:
'''Метод для считывания данных, возможно, поделенных на кадры.'''
try:
data = b''
while True:
chunk = self._connection.recv(1024)
if not chunk:
return None
data += chunk
if not data.endswith(b'\0'):
if b'\0' in data:
# Если после символа конца сообщения есть еще какие-то
# данные -- считаем их наличие ошибкой.
raise WorkerIOError("Unexpected message.")
continue
return json.loads(data[:-1].decode())
except ConnectionResetError:
return None
def _make_connection(self) -> socket.socket:
'''Метод для создания подключения.'''
connection, parent_address = self._sock.accept()
return connection
def _check_connection(self, connection: socket.socket) -> bool: def __enter__(self):
'''Метод для проверки соединения путем отправки на сокет пустого self._connection = self._listener.accept()
сообщения.''' return self
if connection is None:
return False
try: def __exit__(self, exc_type, exc_value, exc_traceback):
connection.sendall(b'') if exc_type:
return True print("exc_type =", exc_type)
except BrokenPipeError: print("exc_value =", exc_value)
return False print("exc_traceback =", exc_traceback)
if exc_type is KeyboardInterrupt:
def __del__(self) -> None: print("correctly")
if self._connection is not None: if not self._connection.closed:
self._connection.close() self._connection.close()
self._sock.close()
class Worker:
def __init__(self, wid: int, command: Command, datavars: Datavars):
self._wid: int = wid
self._datavars: Datavars = datavars
self._socket_path: str = f"./worker_{self._wid}.sock"
if os.path.exists(self._socket_path):
os.remove(self._socket_path)
self._input_queue: list = []
def run(self):
io = IOModule(self._socket_path)
title = io.receive()
print(title['msg'])
while True:
msg = "What is your desire?"
print(">", msg)
answer = io.input(msg)
print(f'> {answer}')
if answer == "stop":
break
elif answer == "output":
msg = "What kind of output you wanna see?"
print(">", msg)
answer = io.input(msg)
io.set_info(answer)
else:
msg = "I am sorry, but I could not help you("
print(">", msg)
io.output(msg)
print('STOPPED')
def initialize(self, io: IOModule):
pass
def run_command(self):
pass
class DeprecatedWorker:
def __init__(self, wid, loop, sockets_path: str = 'calculate/server/'):
self._wid = wid
self._event_loop = loop
# Создаем сокет для взаимодействия воркера и сервера.
socket_path = os.path.join(sockets_path, f'worker_{self._wid}')
if os.path.exists(socket_path):
os.remove(socket_path)
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
self._socket.bind(socket_path)
@staticmethod
def _get_output(output_queue: Queue) -> dict:
return output_queue.get()
@staticmethod
def _main_loop(command, in_queue: Queue, out_queue: Queue):
data = in_queue.get()
print('\nworker for command:', command)
output = {"type": "log",
"level": "INFO",
"msg": f"recieved message {data['text']}"}
out_queue.put(output)
def run(self, command):
'''Метод для запуска процесса воркера с заданным '''
worker_process = Process(target=self._main_loop,
args=(command,
self._in_queue,
self._output_queue))
worker_process.start()

@ -6,7 +6,7 @@ from ...utils.images import ImageMagick, ImageMagickError
from ...variables.loader import Datavars from ...variables.loader import Datavars
from .base_format import Format, FormatError from .base_format import Format, FormatError
from typing import Union, List, Tuple, NoReturn from typing import Union, List, Tuple
import hashlib import hashlib
import re import re
import os import os
@ -296,7 +296,7 @@ class BackgroundsFormat(Format):
return md5_object.hexdigest() return md5_object.hexdigest()
def _create_md5sum_file(self, target_path: str, action_md5sum: str def _create_md5sum_file(self, target_path: str, action_md5sum: str
) -> NoReturn: ) -> None:
"""Метод для создания файла с md5-суммой действия, выполненного """Метод для создания файла с md5-суммой действия, выполненного
данным шаблоном.""" данным шаблоном."""
if not self._empty_name: if not self._empty_name:

@ -23,6 +23,8 @@ class Format:
CALCULATE_VERSION: Union[str, None] = None CALCULATE_VERSION: Union[str, None] = None
SHEBANG_PATTERN: str = r"^(?P<shebang>#!\s*[\w\d\/]+\n)" SHEBANG_PATTERN: str = r"^(?P<shebang>#!\s*[\w\d\/]+\n)"
comment_symbol = None
def __init__(self, processing_methods: List[Callable]): def __init__(self, processing_methods: List[Callable]):
self._processing_methods: List[Callable] = processing_methods self._processing_methods: List[Callable] = processing_methods
self._document_dictionary: OrderedDict = OrderedDict() self._document_dictionary: OrderedDict = OrderedDict()

@ -1,13 +1,27 @@
# vim: fileencoding=utf-8 # vim: fileencoding=utf-8
# #
# ToDo: добавить проверку того, полностью ли парсился документ. Если отпарсился # TODO: добавить проверку того, полностью ли парсился документ. Если отпарсился
# не весь файл -- выдаем ошибку. # не весь файл -- выдаем ошибку.
# #
from .base_format import Format from .base_format import Format
from collections import OrderedDict from collections import OrderedDict
from pyparsing import originalTextFor, OneOrMore, Word, alphanums, Literal,\ from pyparsing import (
ZeroOrMore, Forward, Optional, Group, restOfLine,\ originalTextFor,
cppStyleComment, Keyword, printables, nums, SkipTo OneOrMore,
Word,
alphanums,
Literal,
ZeroOrMore,
Forward,
Optional,
Group,
restOfLine,
cppStyleComment,
Keyword,
printables,
nums,
SkipTo
)
class BINDFormat(Format): class BINDFormat(Format):

@ -1,6 +1,6 @@
# vim: fileencoding=utf-8 # vim: fileencoding=utf-8
# #
# ToDo: написать счетчик скобок для финальной оценки корректности # TODO: написать счетчик скобок для финальной оценки корректности
# документа. # документа.
# #
from .base_format import Format from .base_format import Format

@ -1,11 +1,20 @@
# vim: fileencoding=utf-8 # vim: fileencoding=utf-8
# #
from .base_format import Format from .base_format import Format
from ..template_engine import ParametersContainer
from collections import OrderedDict from collections import OrderedDict
from pyparsing import originalTextFor, Literal, ZeroOrMore, Word, printables,\ from pyparsing import (
OneOrMore, alphanums, ParseException, restOfLine,\ originalTextFor,
Group, Optional, Regex Literal,
ZeroOrMore,
Word,
printables,
OneOrMore,
alphanums,
ParseException,
restOfLine,
Group,
Optional
)
class KDEFormat(Format): class KDEFormat(Format):

@ -1,11 +1,18 @@
# vim: fileencoding=utf-8 # vim: fileencoding=utf-8
# #
from .base_format import Format from .base_format import Format
from ..template_engine import ParametersContainer
from collections import OrderedDict from collections import OrderedDict
from pyparsing import Word, Literal, alphanums, originalTextFor,\ from pyparsing import (
OneOrMore, ParseException, restOfLine, Group, Optional,\ Word,
Regex Literal,
alphanums,
originalTextFor,
ParseException,
restOfLine,
Group,
Optional,
Regex
)
class KernelFormat(Format): class KernelFormat(Format):

@ -2,7 +2,6 @@
# #
from .base_format import Format, FormatError from .base_format import Format, FormatError
from calculate.utils.files import Process from calculate.utils.files import Process
from typing import NoReturn
from os import path from os import path
@ -25,7 +24,7 @@ class PatchFormat(Format):
# Предупреждения. # Предупреждения.
self._warnings: list = [] self._warnings: list = []
def _init_command(self) -> NoReturn: def _init_command(self) -> None:
self._patch_cmd = "/usr/bin/patch" self._patch_cmd = "/usr/bin/patch"
if not path.exists(self._patch_cmd): if not path.exists(self._patch_cmd):
self._patch_cmd = None self._patch_cmd = None

@ -2,9 +2,19 @@
# #
from .base_format import Format from .base_format import Format
from collections import OrderedDict from collections import OrderedDict
from pyparsing import Word, Literal, alphanums, printables, originalTextFor,\ from pyparsing import (
OneOrMore, ParseException, restOfLine, Group, Optional,\ Word,
Regex Literal,
alphanums,
printables,
originalTextFor,
OneOrMore,
ParseException,
restOfLine,
Group,
Optional,
Regex
)
class ProcmailFormat(Format): class ProcmailFormat(Format):

@ -1,7 +1,6 @@
# vim: fileencoding=utf-8 # vim: fileencoding=utf-8
# #
from .base_format import Format from .base_format import Format
from ..template_engine import ParametersContainer
from collections import OrderedDict from collections import OrderedDict
from pyparsing import originalTextFor, Literal, Word, printables, OneOrMore,\ from pyparsing import originalTextFor, Literal, Word, printables, OneOrMore,\
alphanums, ParseException, Optional, Group, restOfLine alphanums, ParseException, Optional, Group, restOfLine
@ -26,7 +25,6 @@ class SambaFormat(Format):
join_before=False, join_before=False,
add_header=False, add_header=False,
already_changed=False, already_changed=False,
parameters=ParametersContainer(),
**kwargs): **kwargs):
processing_methods = [self._parse_comment_line, processing_methods = [self._parse_comment_line,
self._parse_section_line, self._parse_section_line,

@ -4,17 +4,19 @@ from jinja2.ext import Extension
from jinja2.lexer import Token from jinja2.lexer import Token
from jinja2.parser import Parser from jinja2.parser import Parser
from jinja2 import ( from jinja2 import (
nodes,
Template,
Environment, Environment,
contextfunction,
FileSystemLoader, FileSystemLoader,
TemplateSyntaxError, TemplateSyntaxError,
nodes,
contextfunction,
Template,
) )
from jinja2.utils import missing from jinja2.utils import missing
from jinja2.runtime import Context, Undefined from jinja2.runtime import Context, Undefined
from collections.abc import MutableMapping from collections.abc import MutableMapping
from collections import OrderedDict from collections import OrderedDict
from importlib import import_module from importlib import import_module
from pprint import pprint from pprint import pprint
import copy import copy
@ -22,37 +24,38 @@ import re
import os import os
import stat import stat
from typing import ( from typing import (
Optional,
Union, Union,
Any,
List, List,
Any,
Dict,
Tuple, Tuple,
NoReturn,
Optional,
Iterator, Iterator,
) )
from ..utils.package import ( from ..utils.package import (
PackageAtomName,
PackageAtomParser, PackageAtomParser,
PackageAtomError, PackageAtomError,
Package, PackageAtomName,
NOTEXIST, NOTEXIST,
Version Package,
Version,
) )
from ..utils.files import ( from ..utils.files import (
join_paths,
check_directory_link, check_directory_link,
check_command, check_command,
FilesError join_paths,
FilesError,
) )
from calculate.variables.datavars import ( from calculate.variables.datavars import (
VariableNotFoundError, VariableNotFoundError,
HashType,
NamespaceNode, NamespaceNode,
VariableNode, VariableNode,
IniType,
IntegerType, IntegerType,
FloatType, FloatType,
ListType ListType,
HashType,
IniType,
) )
from calculate.utils.fs import readFile from calculate.utils.fs import readFile
from calculate.variables.loader import Datavars from calculate.variables.loader import Datavars
@ -116,10 +119,10 @@ class Variables(MutableMapping):
def __getitem__(self, name: str) -> Any: def __getitem__(self, name: str) -> Any:
return self.__attrs[name] return self.__attrs[name]
def __setitem__(self, name: str, value: Any) -> NoReturn: def __setitem__(self, name: str, value: Any) -> None:
self.__attrs[name] = value self.__attrs[name] = value
def __delitem__(self, name: str) -> NoReturn: def __delitem__(self, name: str) -> None:
del self.__attrs[name] del self.__attrs[name]
def __iter__(self) -> Iterator: def __iter__(self) -> Iterator:
@ -254,7 +257,7 @@ class ParametersProcessor:
def set_parameters_container(self, def set_parameters_container(self,
parameters_container: "ParametersContainer" parameters_container: "ParametersContainer"
) -> NoReturn: ) -> None:
'''Метод для установки текущего контейнера параметров.''' '''Метод для установки текущего контейнера параметров.'''
self._parameters_container = parameters_container self._parameters_container = parameters_container
self._added_parameters = set() self._added_parameters = set()
@ -264,7 +267,7 @@ class ParametersProcessor:
return self._for_package return self._for_package
@for_package.setter @for_package.setter
def for_package(self, package: Package) -> NoReturn: def for_package(self, package: Package) -> None:
self._for_package = package self._for_package = package
def __getattr__(self, parameter_name: str) -> Any: def __getattr__(self, parameter_name: str) -> Any:
@ -278,7 +281,7 @@ class ParametersProcessor:
def check_template_parameter(self, parameter_name: str, def check_template_parameter(self, parameter_name: str,
parameter_value: Any, parameter_value: Any,
template_type: int, lineno: int) -> NoReturn: template_type: int, lineno: int) -> None:
'''Метод, проверяющий указанный параметр.''' '''Метод, проверяющий указанный параметр.'''
self.lineno = lineno self.lineno = lineno
self.template_type = template_type self.template_type = template_type
@ -313,7 +316,7 @@ class ParametersProcessor:
self._parameters_container.set_parameter({parameter_name: self._parameters_container.set_parameter({parameter_name:
checked_value}) checked_value})
def check_postparse_parameters(self) -> NoReturn: def check_postparse_parameters(self) -> None:
'''Метод, запускающий проверку параметров после их разбора.''' '''Метод, запускающий проверку параметров после их разбора.'''
for parameter, parameter_checker in\ for parameter, parameter_checker in\
self.postparse_checkers_list.items(): self.postparse_checkers_list.items():
@ -326,7 +329,7 @@ class ParametersProcessor:
result) result)
def check_template_parameters(self, parameters: dict, def check_template_parameters(self, parameters: dict,
template_type: int, lineno: int) -> NoReturn: template_type: int, lineno: int) -> None:
'''Метод, запускающий проверку указанных параметров.''' '''Метод, запускающий проверку указанных параметров.'''
self.template_type = template_type self.template_type = template_type
self.lineno = lineno self.lineno = lineno
@ -638,7 +641,7 @@ class ParametersProcessor:
return parameter_value return parameter_value
# Методы для проверки параметров после разбора всего шаблона. # Методы для проверки параметров после разбора всего шаблона.
def check_postparse_append(self, parameter_value: str) -> NoReturn: def check_postparse_append(self, parameter_value: str) -> None:
if parameter_value == 'link': if parameter_value == 'link':
if 'source' not in self._parameters_container: if 'source' not in self._parameters_container:
raise IncorrectParameter("append = 'link' without source " raise IncorrectParameter("append = 'link' without source "
@ -652,7 +655,7 @@ class ParametersProcessor:
raise IncorrectParameter("'append' parameter is not 'compatible' " raise IncorrectParameter("'append' parameter is not 'compatible' "
"with the 'exec' parameter") "with the 'exec' parameter")
def check_postparse_run(self, parameter_value: str) -> NoReturn: def check_postparse_run(self, parameter_value: str) -> None:
if self._parameters_container.append: if self._parameters_container.append:
raise IncorrectParameter("'run' parameter is not 'compatible' " raise IncorrectParameter("'run' parameter is not 'compatible' "
"with the 'append' parameter") "with the 'append' parameter")
@ -661,7 +664,7 @@ class ParametersProcessor:
raise IncorrectParameter("'run' parameter is not 'compatible' " raise IncorrectParameter("'run' parameter is not 'compatible' "
"with the 'exec' parameter") "with the 'exec' parameter")
def check_postparse_exec(self, parameter_value: str) -> NoReturn: def check_postparse_exec(self, parameter_value: str) -> None:
if self._parameters_container.append: if self._parameters_container.append:
raise IncorrectParameter("'exec' parameter is not 'compatible' " raise IncorrectParameter("'exec' parameter is not 'compatible' "
"with the 'append' parameter") "with the 'append' parameter")
@ -672,7 +675,7 @@ class ParametersProcessor:
def check_postparse_source(self, def check_postparse_source(self,
parameter_value: Union[str, Tuple[bool, str]] parameter_value: Union[str, Tuple[bool, str]]
) -> NoReturn: ) -> None:
# Если файл по пути source не существует, но присутствует параметр # Если файл по пути source не существует, но присутствует параметр
# mirror -- пропускаем шаблон для того, чтобы целевой файл мог быть # mirror -- пропускаем шаблон для того, чтобы целевой файл мог быть
# удален в исполнительном модуле. # удален в исполнительном модуле.
@ -692,12 +695,12 @@ class ParametersProcessor:
"append = 'link' for directory template") "append = 'link' for directory template")
) )
def check_postparse_autoupdate(self, parameter_value: bool) -> NoReturn: def check_postparse_autoupdate(self, parameter_value: bool) -> None:
if self._parameters_container.unbound: if self._parameters_container.unbound:
raise IncorrectParameter("'unbound' parameter is incompatible" raise IncorrectParameter("'unbound' parameter is incompatible"
" with 'autoupdate' parameter") " with 'autoupdate' parameter")
def check_postparse_handler(self, parameter_value: bool) -> NoReturn: def check_postparse_handler(self, parameter_value: bool) -> None:
if self._parameters_container.merge: if self._parameters_container.merge:
raise IncorrectParameter("'merge' parameter is not available" raise IncorrectParameter("'merge' parameter is not available"
" in handler templates") " in handler templates")
@ -706,7 +709,7 @@ class ParametersProcessor:
raise IncorrectParameter("'package' parameter is not available" raise IncorrectParameter("'package' parameter is not available"
" in handler templates") " in handler templates")
def check_postparse_package(self, parameter_value: str) -> NoReturn: def check_postparse_package(self, parameter_value: str) -> None:
groups = [] groups = []
package_atom = PackageAtomParser.parse_atom_name(parameter_value) package_atom = PackageAtomParser.parse_atom_name(parameter_value)
@ -760,19 +763,19 @@ class ParametersProcessor:
return True return True
return False return False
def check_postparse_convert(self, parameter_value: str) -> NoReturn: def check_postparse_convert(self, parameter_value: str) -> None:
template_format = self._parameters_container.format template_format = self._parameters_container.format
if not template_format or template_format != "backgrounds": if not template_format or template_format != "backgrounds":
raise IncorrectParameter("'convert' parameter available for" raise IncorrectParameter("'convert' parameter available for"
" 'backgrounds' format only.") " 'backgrounds' format only.")
def check_postparse_stretch(self, parameter_value: str) -> NoReturn: def check_postparse_stretch(self, parameter_value: str) -> None:
template_format = self._parameters_container.format template_format = self._parameters_container.format
if not template_format or template_format != "backgrounds": if not template_format or template_format != "backgrounds":
raise IncorrectParameter("'stretch' parameter available for" raise IncorrectParameter("'stretch' parameter available for"
" 'backgrounds' format only.") " 'backgrounds' format only.")
def check_postparse_execsql(self, parameter_value: str) -> NoReturn: def check_postparse_execsql(self, parameter_value: str) -> None:
template_format = self._parameters_container.format template_format = self._parameters_container.format
if not template_format or template_format != "sqlite": if not template_format or template_format != "sqlite":
raise IncorrectParameter("'execsql' parameter available for" raise IncorrectParameter("'execsql' parameter available for"
@ -876,7 +879,7 @@ class ParametersProcessor:
format(group_file_path)) format(group_file_path))
@classmethod @classmethod
def _inspect_formats_package(cls) -> NoReturn: def _inspect_formats_package(cls) -> None:
'''Метод для определения множества доступных форматов и '''Метод для определения множества доступных форматов и
предоставляемых ими параметров.''' предоставляемых ими параметров.'''
if cls.format_is_inspected: if cls.format_is_inspected:
@ -901,6 +904,7 @@ class ParametersProcessor:
module = import_module('calculate.templates.format.{}'. module = import_module('calculate.templates.format.{}'.
format(module_name)) format(module_name))
for obj in dir(module): for obj in dir(module):
# TODO изменить способ выборки классов форматов.
if obj.endswith('Format') and obj != 'BaseFormat': if obj.endswith('Format') and obj != 'BaseFormat':
format_class = getattr(module, obj, False) format_class = getattr(module, obj, False)
@ -954,6 +958,11 @@ class CalculateContext(Context):
сохранять их.''' сохранять их.'''
_env_set = set() _env_set = set()
def __init__(self, environment: Environment,
parent: Union[Context, Dict[str, Any]],
name: str, blocks: Dict[str, Any], **kwargs):
super().__init__(environment, parent, name, blocks, **kwargs)
def resolve(self, key: str) -> Any: def resolve(self, key: str) -> Any:
if self._legacy_resolve_mode: if self._legacy_resolve_mode:
rv = resolve_or_missing(self, key, rv = resolve_or_missing(self, key,
@ -987,19 +996,19 @@ class ParametersContainer(MutableMapping):
else: else:
self.__inheritable: dict = {} self.__inheritable: dict = {}
def set_parameter(self, item_to_add: dict) -> NoReturn: def set_parameter(self, item_to_add: dict) -> None:
self.__parameters.update(item_to_add) self.__parameters.update(item_to_add)
def set_inheritable(self, item_to_add: dict) -> NoReturn: def set_inheritable(self, item_to_add: dict) -> None:
self.__inheritable.update(item_to_add) self.__inheritable.update(item_to_add)
def get_inheritables(self) -> "ParametersContainer": def get_inheritables(self) -> "ParametersContainer":
return ParametersContainer(copy.deepcopy(self.__inheritable)) return ParametersContainer(copy.deepcopy(self.__inheritable))
def remove_not_inheritable(self) -> NoReturn: def remove_not_inheritable(self) -> None:
self.__parameters.clear() self.__parameters.clear()
def print_parameters_for_debug(self) -> NoReturn: def print_parameters_for_debug(self) -> None:
print('Parameters:') print('Parameters:')
pprint(self.__parameters) pprint(self.__parameters)
@ -1010,19 +1019,19 @@ class ParametersContainer(MutableMapping):
return (parameter_name not in self.__parameters return (parameter_name not in self.__parameters
and parameter_name in self.__inheritable) and parameter_name in self.__inheritable)
def remove_parameter(self, parameter_name: str) -> NoReturn: def remove_parameter(self, parameter_name: str) -> None:
if parameter_name in self.__parameters: if parameter_name in self.__parameters:
self.__parameters.pop(parameter_name) self.__parameters.pop(parameter_name)
elif parameter_name in self.__inheritable: elif parameter_name in self.__inheritable:
self.__inheritable.pop(parameter_name) self.__inheritable.pop(parameter_name)
def change_parameter(self, parameter: str, value: Any) -> NoReturn: def change_parameter(self, parameter: str, value: Any) -> None:
if parameter in self.__parameters: if parameter in self.__parameters:
self.__parameters.update({parameter: value}) self.__parameters.update({parameter: value})
elif parameter in self.__inheritable: elif parameter in self.__inheritable:
self.__inheritable.update({parameter: value}) self.__inheritable.update({parameter: value})
def _clear_container(self) -> NoReturn: def _clear_container(self) -> None:
self.__parameters.clear() self.__parameters.clear()
self.__inheritable.clear() self.__inheritable.clear()
@ -1047,10 +1056,10 @@ class ParametersContainer(MutableMapping):
else: else:
return False return False
def __setitem__(self, name: str, value: Any) -> NoReturn: def __setitem__(self, name: str, value: Any) -> None:
self.__parameters[name] = value self.__parameters[name] = value
def __delitem__(self, name: str) -> NoReturn: def __delitem__(self, name: str) -> None:
if name in self.__parameters: if name in self.__parameters:
del self.__parameters[name] del self.__parameters[name]
@ -1092,9 +1101,9 @@ class CalculateExtension(Extension):
self.environment: Environment = environment self.environment: Environment = environment
self.package_atom_parser = PackageAtomParser(chroot_path=chroot_path) self.package_atom_parser = PackageAtomParser(chroot_path=chroot_path)
self.environment.globals.update({'pkg': self.pkg}) self.environment.globals.update({'pkg': self.pkg,
self.environment.globals.update({'grep': self.grep}) 'grep': self.grep,
self.environment.globals.update({'exists': self.exists}) 'exists': self.exists})
self._datavars = datavars_module self._datavars = datavars_module
self.parameters_processor = parameters_processor self.parameters_processor = parameters_processor
@ -1104,7 +1113,7 @@ class CalculateExtension(Extension):
# того, чтобы проверять единственность тега calculate. # того, чтобы проверять единственность тега calculate.
self.calculate_parsed: bool = False self.calculate_parsed: bool = False
self.tags = {'calculate', 'save', 'set_var'} self.tags = {'calculate', 'save'}
self.CONDITION_TOKENS_TYPES = {'eq', 'ne', 'lt', 'gt', 'lteq', 'gteq'} self.CONDITION_TOKENS_TYPES = {'eq', 'ne', 'lt', 'gt', 'lteq', 'gteq'}
self.CONDITION_NAME_TOKENS = {'not'} self.CONDITION_NAME_TOKENS = {'not'}
self.LITERAL_TOKENS_TYPES = {'string', 'integer', 'float'} self.LITERAL_TOKENS_TYPES = {'string', 'integer', 'float'}
@ -1121,6 +1130,8 @@ class CalculateExtension(Extension):
def __call__(self, env: Environment) -> "CalculateExtension": def __call__(self, env: Environment) -> "CalculateExtension":
# Необходимо для обеспечения возможности передать готовый объект # Необходимо для обеспечения возможности передать готовый объект
# расширения, а не его класс. # расширения, а не его класс.
# Через функцию-фабрику такое не получается устроить, потому что при
# добавлении расширения jinja2 ищет у его класса атрибут identifier.
return self return self
def parse(self, parser: Parser) -> List[nodes.Output]: def parse(self, parser: Parser) -> List[nodes.Output]:
@ -1285,8 +1296,10 @@ class CalculateExtension(Extension):
.format(str(error)), .format(str(error)),
lineno=self.stream.current.lineno) lineno=self.stream.current.lineno)
def check_conditions(self, conditions: List[Template]) -> NoReturn: def check_conditions(self, conditions: List[Template]) -> None:
for condition in conditions: for condition in conditions:
# TODO Добавить различие провала условие из синтаксической ошибки
# или при отрицательном условии.
self.condition_result = False self.condition_result = False
try: try:
condition.render(__datavars__=self._datavars) condition.render(__datavars__=self._datavars)
@ -1423,7 +1436,7 @@ class CalculateExtension(Extension):
def _modify_variables(self, variable: List[str], namespace: NamespaceNode, def _modify_variables(self, variable: List[str], namespace: NamespaceNode,
new_value: Any, optype: int, new_value: Any, optype: int,
target_file: Optional[str] = None, target_file: Optional[str] = None,
modify_only: bool = True) -> NoReturn: modify_only: bool = True) -> None:
'''Метод для модификации значения переменной.''' '''Метод для модификации значения переменной.'''
variable_name = variable[-1] variable_name = variable[-1]
@ -1457,7 +1470,7 @@ class CalculateExtension(Extension):
# DEPRECATED # DEPRECATED
def _modify_hash(self, variable_name: List[str], def _modify_hash(self, variable_name: List[str],
hash_variable: VariableNode, new_value, optype, hash_variable: VariableNode, new_value, optype,
target_file: Optional[str] = None) -> NoReturn: target_file: Optional[str] = None) -> None:
'''Метод для модификации значения в переменной-хэше.''' '''Метод для модификации значения в переменной-хэше.'''
value_name = variable_name[-1] value_name = variable_name[-1]
hash_value = hash_variable.get_value().get_hash() hash_value = hash_variable.get_value().get_hash()
@ -1477,7 +1490,7 @@ class CalculateExtension(Extension):
def _save_to_target(self, namespace_name: List[str], def _save_to_target(self, namespace_name: List[str],
variable_name: str, value: Any, target_file: str variable_name: str, value: Any, target_file: str
) -> NoReturn: ) -> None:
'''Метод для добавления переменной в список переменных, значение '''Метод для добавления переменной в список переменных, значение
которых было установлено через тег save и при этом должно быть которых было установлено через тег save и при этом должно быть
сохранено в указанном файле: save.target_file.''' сохранено в указанном файле: save.target_file.'''
@ -1677,7 +1690,7 @@ class CalculateExtension(Extension):
class TemplateEngine: class TemplateEngine:
def __init__(self, directory_path: Union[str, None] = None, def __init__(self, directory_path: Optional[str] = None,
datavars_module: Union[Datavars, datavars_module: Union[Datavars,
NamespaceNode, NamespaceNode,
Variables] = Variables(), Variables] = Variables(),
@ -1728,16 +1741,16 @@ class TemplateEngine:
return self.parameters_processor.for_package return self.parameters_processor.for_package
@for_package.setter @for_package.setter
def for_package(self, package: Package) -> NoReturn: def for_package(self, package: Package) -> None:
self.parameters_processor.for_package = package self.parameters_processor.for_package = package
def change_directory(self, directory_path: str) -> NoReturn: def change_directory(self, directory_path: str) -> None:
'''Метод для смены директории в загрузчике.''' '''Метод для смены директории в загрузчике.'''
self.environment.loader = FileSystemLoader(directory_path) self.environment.loader = FileSystemLoader(directory_path)
def process_template(self, template_path: str, template_type: str, def process_template(self, template_path: str, template_type: str,
parameters: Optional[ParametersContainer] = None parameters: Optional[ParametersContainer] = None
) -> NoReturn: ) -> None:
'''Метод для обработки файла шаблона, расположенного по указанному '''Метод для обработки файла шаблона, расположенного по указанному
пути.''' пути.'''
if parameters is not None: if parameters is not None:
@ -1767,7 +1780,7 @@ class TemplateEngine:
def process_template_from_string( def process_template_from_string(
self, string: str, template_type: int, self, string: str, template_type: int,
parameters: Optional[ParametersContainer] = None parameters: Optional[ParametersContainer] = None
) -> NoReturn: ) -> None:
'''Метод для обработки текста шаблона.''' '''Метод для обработки текста шаблона.'''
if parameters is not None: if parameters is not None:
self._parameters_object = parameters self._parameters_object = parameters

@ -43,7 +43,6 @@ from typing import (
List, List,
Tuple, Tuple,
Iterator, Iterator,
NoReturn,
Optional, Optional,
Callable Callable
) )
@ -123,21 +122,21 @@ class CalculateConfigFile:
self._unsaved_changes = False self._unsaved_changes = False
return config_dictionary return config_dictionary
def set_files_md5(self, file_path: str, file_md5: str) -> NoReturn: def set_files_md5(self, file_path: str, file_md5: str) -> None:
'''Метод для установки в config соответствия файла некоторой '''Метод для установки в config соответствия файла некоторой
контрольной сумме.''' контрольной сумме.'''
file_path = self._remove_chroot(file_path) file_path = self._remove_chroot(file_path)
self._config_dictionary[file_path] = file_md5 self._config_dictionary[file_path] = file_md5
self._unsaved_changes = True self._unsaved_changes = True
def remove_file(self, file_path: str) -> NoReturn: def remove_file(self, file_path: str) -> None:
'''Метод для удаления файла из config.''' '''Метод для удаления файла из config.'''
file_path = self._remove_chroot(file_path) file_path = self._remove_chroot(file_path)
if file_path in self._config_dictionary: if file_path in self._config_dictionary:
self._config_dictionary.pop(file_path) self._config_dictionary.pop(file_path)
self._unsaved_changes = True self._unsaved_changes = True
def compare_md5(self, file_path: str, file_md5: str) -> NoReturn: def compare_md5(self, file_path: str, file_md5: str) -> None:
'''Метод для сравнения хэш-суммы из config и некоторой заданной.''' '''Метод для сравнения хэш-суммы из config и некоторой заданной.'''
file_path = self._remove_chroot(file_path) file_path = self._remove_chroot(file_path)
if file_path in self._config_dictionary: if file_path in self._config_dictionary:
@ -145,7 +144,7 @@ class CalculateConfigFile:
else: else:
return False return False
def save_changes(self) -> NoReturn: def save_changes(self) -> None:
'''Метод для записи изменений, внессенных в файл config.''' '''Метод для записи изменений, внессенных в файл config.'''
if not self._unsaved_changes: if not self._unsaved_changes:
return return
@ -168,7 +167,6 @@ class CalculateConfigFile:
class TemplateWrapper: class TemplateWrapper:
'''Класс связывающий шаблон с целевым файлом и определяющий параметры '''Класс связывающий шаблон с целевым файлом и определяющий параметры
наложения шаблона, обусловленные состоянием целевого файла.''' наложения шаблона, обусловленные состоянием целевого файла.'''
type_checks: Dict[int, type_checks: Dict[int,
Callable[[str], bool]] = {DIR: os.path.isdir, Callable[[str], bool]] = {DIR: os.path.isdir,
FILE: os.path.isfile} FILE: os.path.isfile}
@ -264,7 +262,7 @@ class TemplateWrapper:
raise TemplateExecutorError("'format' parameter is not set" raise TemplateExecutorError("'format' parameter is not set"
" file template.") " file template.")
# Если по этому пути что-то есть -- проверяем конфликты. # Если по этому пути что-то есть -- проверяем тип этого.
if os.path.exists(target_file_path): if os.path.exists(target_file_path):
for file_type, checker in self.type_checks.items(): for file_type, checker in self.type_checks.items():
if checker(target_file_path): if checker(target_file_path):
@ -312,7 +310,7 @@ class TemplateWrapper:
# self.parameters.append == "replace"): # self.parameters.append == "replace"):
# self.remove_original = True # self.remove_original = True
def _check_type_conflicts(self) -> NoReturn: def _check_type_conflicts(self) -> None:
'''Метод для проверки конфликтов типов.''' '''Метод для проверки конфликтов типов.'''
if self.parameters.append == 'link': if self.parameters.append == 'link':
if self.parameters.force: if self.parameters.force:
@ -386,7 +384,7 @@ class TemplateWrapper:
raise TemplateTypeConflict("the target file is a directory" raise TemplateTypeConflict("the target file is a directory"
" while the template is a file") " while the template is a file")
def _check_package_collision(self) -> NoReturn: def _check_package_collision(self) -> None:
'''Метод для проверки на предмет коллизии, то есть конфликта пакета '''Метод для проверки на предмет коллизии, то есть конфликта пакета
шаблона и целевого файла.''' шаблона и целевого файла.'''
if self.parameters.package: if self.parameters.package:
@ -475,7 +473,9 @@ class TemplateWrapper:
def _compare_packages(self, lpackage: PackageAtomName, def _compare_packages(self, lpackage: PackageAtomName,
rpackage: PackageAtomName rpackage: PackageAtomName
) -> Union[None, PackageAtomName]: ) -> Union[None, PackageAtomName]:
'''Метод, сравнивающий пакеты по их именам, возвращает старший.''' '''Метод, сравнивающий пакеты по их именам, возвращает старший, если
пакеты с одинаковыми именами, но разными версиями, или None, если их
имена и категории не равны.'''
if lpackage.category != rpackage.category: if lpackage.category != rpackage.category:
return None return None
@ -487,7 +487,7 @@ class TemplateWrapper:
else: else:
return rpackage return rpackage
def _check_user_changes(self) -> NoReturn: def _check_user_changes(self) -> None:
'''Метод для проверки наличия пользовательских изменений в '''Метод для проверки наличия пользовательских изменений в
конфигурационных файлах.''' конфигурационных файлах.'''
# Эта проверка только для файлов. # Эта проверка только для файлов.
@ -520,8 +520,7 @@ class TemplateWrapper:
# Путь к архивной версии файла. # Путь к архивной версии файла.
self.archive_path = self._get_archive_path(self.target_path) self.archive_path = self._get_archive_path(self.target_path)
self.contents_matching = (self.parameters.autoupdate self.contents_matching = (self.parameters.autoupdate)
or self.parameters.force)
if not self.protected: if not self.protected:
self.contents_matching = True self.contents_matching = True
@ -545,6 +544,8 @@ class TemplateWrapper:
# Если файл по целевому пути не относится к какому-либо пакету. # Если файл по целевому пути не относится к какому-либо пакету.
# self.contents_matching = False # self.contents_matching = False
pass pass
elif self.target_type is DIR and self.parameters.force:
self.contents_matching = True
elif not self.contents_matching: elif not self.contents_matching:
# Если файл есть и он относится к текущему пакету. # Если файл есть и он относится к текущему пакету.
# Если по каким-то причинам уже нужно считать, что хэш-суммы # Если по каким-то причинам уже нужно считать, что хэш-суммы
@ -613,7 +614,7 @@ class TemplateWrapper:
return new_cfg_path return new_cfg_path
def remove_from_contents(self) -> NoReturn: def remove_from_contents(self) -> None:
'''Метод для удаления целевого файла из CONTENTS.''' '''Метод для удаления целевого файла из CONTENTS.'''
if self.target_package is None: if self.target_package is None:
return return
@ -623,13 +624,13 @@ class TemplateWrapper:
elif self.template_type == FILE: elif self.template_type == FILE:
self.target_package.remove_obj(self.target_path) self.target_package.remove_obj(self.target_path)
def clear_dir_contents(self) -> NoReturn: def clear_dir_contents(self) -> None:
'''Метод для удаления из CONTENTS всего содержимого директории после '''Метод для удаления из CONTENTS всего содержимого директории после
применения append = "clear".''' применения append = "clear".'''
if self.template_type == DIR and self.target_package is not None: if self.template_type == DIR and self.target_package is not None:
self.target_package.clear_dir(self.target_path) self.target_package.clear_dir(self.target_path)
def add_to_contents(self, file_md5: Optional[str] = None) -> NoReturn: def add_to_contents(self, file_md5: Optional[str] = None) -> None:
'''Метод для добавления целевого файла в CONTENTS.''' '''Метод для добавления целевого файла в CONTENTS.'''
if self.target_package is None: if self.target_package is None:
return return
@ -652,7 +653,7 @@ class TemplateWrapper:
elif self.template_type == FILE: elif self.template_type == FILE:
self.target_package.add_obj(source_path, file_md5=file_md5) self.target_package.add_obj(source_path, file_md5=file_md5)
def update_contents_from_list(self, changed_list: dict) -> NoReturn: def update_contents_from_list(self, changed_list: dict) -> None:
'''Метод для изменения CONTENTS по списку измененных файлов.''' '''Метод для изменения CONTENTS по списку измененных файлов.'''
print("UPDATE CONTENTS FROM LIST") print("UPDATE CONTENTS FROM LIST")
if self.target_package is None: if self.target_package is None:
@ -676,7 +677,7 @@ class TemplateWrapper:
self.target_package.add_dir(file_path) self.target_package.add_dir(file_path)
@classmethod @classmethod
def _set_protected(cls, chroot_path: str) -> NoReturn: def _set_protected(cls, chroot_path: str) -> None:
'''Метод для получения множества защищенных директорий.''' '''Метод для получения множества защищенных директорий.'''
cls._protected_set = set() cls._protected_set = set()
cls._unprotected_set = set() cls._unprotected_set = set()
@ -699,7 +700,7 @@ class TemplateWrapper:
cls._protected_is_set = True cls._protected_is_set = True
def save_changes(self) -> NoReturn: def save_changes(self) -> None:
'''Метод для сохранения изменений внесенных в CONTENTS.''' '''Метод для сохранения изменений внесенных в CONTENTS.'''
if self.target_package: if self.target_package:
self.target_package.remove_empty_directories() self.target_package.remove_empty_directories()
@ -864,7 +865,7 @@ class TemplateExecutor:
return self.executor_output return self.executor_output
def save_changes(self) -> NoReturn: def save_changes(self) -> None:
'''Метод для сохранения чего-нибудь после выполнения всех шаблонов.''' '''Метод для сохранения чего-нибудь после выполнения всех шаблонов.'''
# Пока сохраняем только получившееся содержимое config-файла. # Пока сохраняем только получившееся содержимое config-файла.
self.calculate_config_file.save_changes() self.calculate_config_file.save_changes()
@ -892,7 +893,7 @@ class TemplateExecutor:
template_object.add_to_contents() template_object.add_to_contents()
def _append_remove_directory(self, template_object: TemplateWrapper def _append_remove_directory(self, template_object: TemplateWrapper
) -> NoReturn: ) -> None:
'''Метод описывающий действия для append = "remove", если шаблон -- '''Метод описывающий действия для append = "remove", если шаблон --
директория. Удаляет директорию со всем содержимым, если она есть.''' директория. Удаляет директорию со всем содержимым, если она есть.'''
if template_object.target_type is not None: if template_object.target_type is not None:
@ -914,11 +915,11 @@ class TemplateExecutor:
template_object.remove_from_contents() template_object.remove_from_contents()
def _append_skip_directory(self, def _append_skip_directory(self,
template_object: TemplateWrapper) -> NoReturn: template_object: TemplateWrapper) -> None:
pass pass
def _append_clear_directory(self, def _append_clear_directory(self,
template_object: TemplateWrapper) -> NoReturn: template_object: TemplateWrapper) -> None:
'''Метод описывающий действия для append = "clear", если шаблон -- '''Метод описывающий действия для append = "clear", если шаблон --
директория. Удаляет все содержимое директории, если она есть.''' директория. Удаляет все содержимое директории, если она есть.'''
if template_object.target_type is not None: if template_object.target_type is not None:
@ -949,7 +950,7 @@ class TemplateExecutor:
template_object.clear_dir_contents() template_object.clear_dir_contents()
def _append_link_directory(self, template_object: TemplateWrapper def _append_link_directory(self, template_object: TemplateWrapper
) -> NoReturn: ) -> None:
'''Метод описывающий действия для append = "link", если шаблон -- '''Метод описывающий действия для append = "link", если шаблон --
директория. Создает ссылку на директорию, если она есть.''' директория. Создает ссылку на директорию, если она есть.'''
self._link_directory(template_object.parameters.source, self._link_directory(template_object.parameters.source,
@ -974,7 +975,7 @@ class TemplateExecutor:
template_object.add_to_contents() template_object.add_to_contents()
def _append_replace_directory(self, template_object: TemplateWrapper def _append_replace_directory(self, template_object: TemplateWrapper
) -> NoReturn: ) -> None:
'''Метод описывающий действия для append = "replace", если шаблон -- '''Метод описывающий действия для append = "replace", если шаблон --
директория. Очищает директорию или создает, если ее нет.''' директория. Очищает директорию или создает, если ее нет.'''
if template_object.target_type is None: if template_object.target_type is None:
@ -1006,7 +1007,7 @@ class TemplateExecutor:
def _append_join_file(self, template_object: TemplateWrapper, def _append_join_file(self, template_object: TemplateWrapper,
join_before: bool = False, replace: bool = False join_before: bool = False, replace: bool = False
) -> NoReturn: ) -> None:
'''Метод описывающий действия при append = "join", если шаблон -- файл. '''Метод описывающий действия при append = "join", если шаблон -- файл.
Объединяет шаблон с целевым файлом.''' Объединяет шаблон с целевым файлом.'''
output_path = template_object.output_path output_path = template_object.output_path
@ -1105,6 +1106,7 @@ class TemplateExecutor:
chroot_path=self.chroot_path) chroot_path=self.chroot_path)
# Удаляем форматный объект входного файла. # Удаляем форматный объект входного файла.
# del(parsed_template) # del(parsed_template)
# Если исполняемый формат выдал список измененных файлов для # Если исполняемый формат выдал список измененных файлов для
# изменения CONTENTS и при этом задан пакет -- обновляем # изменения CONTENTS и при этом задан пакет -- обновляем
# CONTENTS. # CONTENTS.
@ -1279,32 +1281,32 @@ class TemplateExecutor:
chown = self.file_default_parameters.get('chown', False) chown = self.file_default_parameters.get('chown', False)
return chown return chown
def _append_after_file(self, template_object: TemplateWrapper) -> NoReturn: def _append_after_file(self, template_object: TemplateWrapper) -> None:
'''Метод описывающий действия при append = "after", если шаблон -- '''Метод описывающий действия при append = "after", если шаблон --
файл. Объединяет шаблон с целевым файлом так, чтобы текст добавлялся файл. Объединяет шаблон с целевым файлом так, чтобы текст добавлялся
в конец файла и в конец каждой секции файла.''' в конец файла и в конец каждой секции файла.'''
self._append_join_file(template_object, join_before=False) self._append_join_file(template_object, join_before=False)
def _append_before_file(self, template_object: TemplateWrapper def _append_before_file(self, template_object: TemplateWrapper
) -> NoReturn: ) -> None:
'''Метод описывающий действия при append = "after", если шаблон -- '''Метод описывающий действия при append = "after", если шаблон --
файл. Объединяет шаблон с целевым файлом так, чтобы текст добавлялся файл. Объединяет шаблон с целевым файлом так, чтобы текст добавлялся
в начало файла и в начало каждой секции файла.''' в начало файла и в начало каждой секции файла.'''
self._append_join_file(template_object, join_before=True) self._append_join_file(template_object, join_before=True)
def _append_skip_file(self, template_object: TemplateWrapper) -> NoReturn: def _append_skip_file(self, template_object: TemplateWrapper) -> None:
'''Метод описывающий действия при append = "skip". Пока никаких '''Метод описывающий действия при append = "skip". Пока никаких
действий.''' действий.'''
pass pass
def _append_replace_file(self, template_object: TemplateWrapper def _append_replace_file(self, template_object: TemplateWrapper
) -> NoReturn: ) -> None:
'''Метод описывающий действия при append = "replace", если шаблон -- '''Метод описывающий действия при append = "replace", если шаблон --
файл. Очищает файл и затем накладывает на него шаблон.''' файл. Очищает файл и затем накладывает на него шаблон.'''
self._append_join_file(template_object, replace=True) self._append_join_file(template_object, replace=True)
def _append_remove_file(self, template_object: TemplateWrapper def _append_remove_file(self, template_object: TemplateWrapper
) -> NoReturn: ) -> None:
'''Метод описывающий действия при append = "remove", если шаблон -- '''Метод описывающий действия при append = "remove", если шаблон --
файл. Удаляет файл.''' файл. Удаляет файл.'''
if template_object.target_type is not None: if template_object.target_type is not None:
@ -1320,7 +1322,7 @@ class TemplateExecutor:
if self.dbpkg: if self.dbpkg:
template_object.remove_from_contents() template_object.remove_from_contents()
def _append_clear_file(self, template_object: TemplateWrapper) -> NoReturn: def _append_clear_file(self, template_object: TemplateWrapper) -> None:
'''Метод описывающий действия при append = "clear", если шаблон -- '''Метод описывающий действия при append = "clear", если шаблон --
файл. Очищает файл.''' файл. Очищает файл.'''
if template_object.target_type is not None: if template_object.target_type is not None:
@ -1342,7 +1344,7 @@ class TemplateExecutor:
if self.dbpkg: if self.dbpkg:
template_object.add_to_contents() template_object.add_to_contents()
def _append_link_file(self, template_object: TemplateWrapper) -> NoReturn: def _append_link_file(self, template_object: TemplateWrapper) -> None:
'''Метод описывающий действия при append = "link", если шаблон -- '''Метод описывающий действия при append = "link", если шаблон --
файл. Создает ссылку на файл, указанный в параметре source.''' файл. Создает ссылку на файл, указанный в параметре source.'''
input_path = template_object.input_path input_path = template_object.input_path
@ -1424,7 +1426,7 @@ class TemplateExecutor:
return hashlib.md5(source_path.encode()).hexdigest() return hashlib.md5(source_path.encode()).hexdigest()
def _create_directory(self, template_object: TemplateWrapper, def _create_directory(self, template_object: TemplateWrapper,
path_to_create: Optional[str] = None) -> NoReturn: path_to_create: Optional[str] = None) -> None:
'''Метод для создания директории и, при необходимости, изменения '''Метод для создания директории и, при необходимости, изменения
владельца и доступа все директорий на пути к целевой.''' владельца и доступа все директорий на пути к целевой.'''
if path_to_create is None: if path_to_create is None:
@ -1481,7 +1483,7 @@ class TemplateExecutor:
'Failed to create directory: {}, reason: {}'. 'Failed to create directory: {}, reason: {}'.
format(create_path, str(error))) format(create_path, str(error)))
def _remove_directory(self, target_path: str) -> NoReturn: def _remove_directory(self, target_path: str) -> None:
'''Метод для удаления директории.''' '''Метод для удаления директории.'''
if os.path.exists(target_path): if os.path.exists(target_path):
if os.path.isdir(target_path): if os.path.isdir(target_path):
@ -1505,7 +1507,7 @@ class TemplateExecutor:
"reason: {1}").format(target_path, "reason: {1}").format(target_path,
error_message)) error_message))
def _clear_directory(self, target_path: str) -> NoReturn: def _clear_directory(self, target_path: str) -> None:
'''Метод для очистки содержимого целевой директории.''' '''Метод для очистки содержимого целевой директории.'''
if os.path.exists(target_path): if os.path.exists(target_path):
if os.path.isdir(target_path): if os.path.isdir(target_path):
@ -1526,9 +1528,9 @@ class TemplateExecutor:
error_message)) error_message))
def _link_directory(self, source: str, target_path: str, def _link_directory(self, source: str, target_path: str,
force: bool = False) -> NoReturn: force: bool = False) -> None:
'''Метод для создания по целевому пути ссылки на директорию '''Метод для создания по целевому пути ссылки на директорию
расположенную на пути, указанному в source.''' расположенную на пути, указанном в source.'''
try: try:
os.symlink(source, target_path, target_is_directory=True) os.symlink(source, target_path, target_is_directory=True)
except OSError as error: except OSError as error:
@ -1536,7 +1538,7 @@ class TemplateExecutor:
"failed to create symlink: {0} -> {1}, reason: {2}". "failed to create symlink: {0} -> {1}, reason: {2}".
format(target_path, source, str(error))) format(target_path, source, str(error)))
def _remove_file(self, target_path: str) -> NoReturn: def _remove_file(self, target_path: str) -> None:
'''Метод для удаления файлов.''' '''Метод для удаления файлов.'''
if os.path.exists(target_path): if os.path.exists(target_path):
if os.path.isfile(target_path): if os.path.isfile(target_path):
@ -1567,7 +1569,7 @@ class TemplateExecutor:
"reason: {1}").format(target_path, "reason: {1}").format(target_path,
error_message)) error_message))
def _clear_file(self, target_path: str) -> NoReturn: def _clear_file(self, target_path: str) -> None:
'''Метод для очистки файлов.''' '''Метод для очистки файлов.'''
if os.path.exists(target_path): if os.path.exists(target_path):
if os.path.isfile(target_path): if os.path.isfile(target_path):
@ -1586,7 +1588,7 @@ class TemplateExecutor:
"reason: {1}").format(target_path, "reason: {1}").format(target_path,
error_message)) error_message))
def _link_file(self, source: str, target_path: str) -> NoReturn: def _link_file(self, source: str, target_path: str) -> None:
'''Метод для создания по целевому пути ссылки на файл расположенный на '''Метод для создания по целевому пути ссылки на файл расположенный на
пути, указанному в source.''' пути, указанному в source.'''
try: try:
@ -1596,7 +1598,7 @@ class TemplateExecutor:
"failed to create symlink to the file: {0} -> {1}, reason: {2}". "failed to create symlink to the file: {0} -> {1}, reason: {2}".
format(target_path, source, str(error))) format(target_path, source, str(error)))
def _run_template(self, template_object: TemplateWrapper) -> NoReturn: def _run_template(self, template_object: TemplateWrapper) -> None:
'''Метод для сохранения текста шаблонов, который должен быть исполнен '''Метод для сохранения текста шаблонов, который должен быть исполнен
интерпретатором указанным в run прямо во время обработки шаблонов.''' интерпретатором указанным в run прямо во время обработки шаблонов.'''
text_to_run = template_object.template_text text_to_run = template_object.template_text
@ -1634,7 +1636,7 @@ class TemplateExecutor:
" interpreter '{}', reason: {}"). " interpreter '{}', reason: {}").
format(interpreter, str(error))) format(interpreter, str(error)))
def _exec_template(self, template_object: TemplateWrapper) -> NoReturn: def _exec_template(self, template_object: TemplateWrapper) -> None:
'''Метод для сохранения текста шаблонов, который должен быть исполнен '''Метод для сохранения текста шаблонов, который должен быть исполнен
интерпретатором указанным в exec после выполнения всех прочих шаблонов. интерпретатором указанным в exec после выполнения всех прочих шаблонов.
''' '''
@ -1720,7 +1722,7 @@ class TemplateExecutor:
format(interpreter, str(error))) format(interpreter, str(error)))
def _chown_directory(self, target_path: str, chown_value: dict def _chown_directory(self, target_path: str, chown_value: dict
) -> NoReturn: ) -> None:
"""Метод для смены владельца директории.""" """Метод для смены владельца директории."""
try: try:
if os.path.exists(target_path): if os.path.exists(target_path):
@ -1738,7 +1740,7 @@ class TemplateExecutor:
chown_value['gid']), chown_value['gid']),
str(error))) str(error)))
def _chmod_directory(self, target_path: str, chmod_value: int) -> NoReturn: def _chmod_directory(self, target_path: str, chmod_value: int) -> None:
'''Метод для смены прав доступа к директории.''' '''Метод для смены прав доступа к директории.'''
if isinstance(chmod_value, tuple) and not chmod_value[1]: if isinstance(chmod_value, tuple) and not chmod_value[1]:
chmod_value = chmod_value[0] chmod_value = chmod_value[0]
@ -1759,7 +1761,7 @@ class TemplateExecutor:
'Can not chmod directory: {0}, reason: {1}'. 'Can not chmod directory: {0}, reason: {1}'.
format(target_path, str(error))) format(target_path, str(error)))
def _chown_file(self, target_path: str, chown_value: dict) -> NoReturn: def _chown_file(self, target_path: str, chown_value: dict) -> None:
'''Метод для смены владельца файла.''' '''Метод для смены владельца файла.'''
try: try:
if os.path.exists(target_path): if os.path.exists(target_path):
@ -1777,7 +1779,7 @@ class TemplateExecutor:
chown_value['gid']), chown_value['gid']),
str(error))) str(error)))
def _chmod_file(self, target_path: str, chmod_value: int) -> NoReturn: def _chmod_file(self, target_path: str, chmod_value: int) -> None:
'''Метод для смены прав доступа к директории.''' '''Метод для смены прав доступа к директории.'''
try: try:
if not os.path.exists(target_path): if not os.path.exists(target_path):
@ -1802,13 +1804,15 @@ class TemplateExecutor:
current_mode: Optional[int] = None) -> int: current_mode: Optional[int] = None) -> int:
'''Метод для наложения X-маски, необходимой для получения значения '''Метод для наложения X-маски, необходимой для получения значения
chmod, c учетом возможности наличия в нем значения "X".''' chmod, c учетом возможности наличия в нем значения "X".'''
if not chmod[1]: chmod, x_mask = chmod
return chmod[0]
if not x_mask:
return chmod
if current_mode is None: if current_mode is None:
return chmod[0] ^ chmod[1] return chmod ^ x_mask
else: else:
return chmod[0] ^ (current_mode & chmod[1]) return chmod ^ (current_mode & x_mask)
def _get_file_mode(self, file_path: str) -> int: def _get_file_mode(self, file_path: str) -> int:
'''Метод для получения прав доступа для указанного файла.''' '''Метод для получения прав доступа для указанного файла.'''
@ -1847,7 +1851,7 @@ class TemplateExecutor:
if self.chroot_path == '/': if self.chroot_path == '/':
group_name = grp.getgrgid(gid).gr_name group_name = grp.getgrgid(gid).gr_name
else: else:
group_name = self._get_group_name_form_gid(gid) group_name = self._get_group_name_from_gid(gid)
except (TypeError, KeyError): except (TypeError, KeyError):
group_name = str(gid) group_name = str(gid)
@ -1875,7 +1879,7 @@ class TemplateExecutor:
else: else:
return str(uid) return str(uid)
def _get_group_name_form_gid(self, gid: int) -> str: def _get_group_name_from_gid(self, gid: int) -> str:
'''Метод для получения названия группы по его gid.''' '''Метод для получения названия группы по его gid.'''
group_file_path = os.path.join(self.chroot_path, 'etc/group') group_file_path = os.path.join(self.chroot_path, 'etc/group')
group_dictionary = dict() group_dictionary = dict()
@ -1927,7 +1931,7 @@ class DirectoryTree:
self.base_directory = base_directory self.base_directory = base_directory
self._tree = {} self._tree = {}
def update_tree(self, tree: dict) -> NoReturn: def update_tree(self, tree: dict) -> None:
'''Метод, инициирующий наложение заданного дерева каталогов на данный '''Метод, инициирующий наложение заданного дерева каталогов на данный
экземпляр дерева.''' экземпляр дерева.'''
self._update(self._tree, tree) self._update(self._tree, tree)
@ -1943,7 +1947,7 @@ class DirectoryTree:
original_tree[parent] = child original_tree[parent] = child
return original_tree return original_tree
def show_tree(self) -> NoReturn: def show_tree(self) -> None:
pprint(self._tree) pprint(self._tree)
def get_directory_tree(self, directory: str) -> "DirectoryTree": def get_directory_tree(self, directory: str) -> "DirectoryTree":
@ -1956,13 +1960,13 @@ class DirectoryTree:
directory_tree._tree = self._tree[directory] directory_tree._tree = self._tree[directory]
return directory_tree return directory_tree
def __getitem__(self, name: str) -> dict: def __getitem__(self, name: str) -> Union[None, dict]:
if name in self._tree: if name in self._tree:
return self._tree[name] return self._tree[name]
else: else:
return None return None
def __setitem__(self, name: str, value: Union[None, dict]) -> NoReturn: def __setitem__(self, name: str, value: Union[None, dict]) -> None:
self._tree[name] = value self._tree[name] = value
def __iter__(self) -> Iterator[str]: def __iter__(self) -> Iterator[str]:
@ -2149,7 +2153,7 @@ class DirectoryProcessor:
return path_to_add return path_to_add
def _add_package_to_group(self, group_name: str, def _add_package_to_group(self, group_name: str,
package_atom: str) -> NoReturn: package_atom: str) -> None:
try: try:
groups_namespace = self.datavars_module.main.cl.groups groups_namespace = self.datavars_module.main.cl.groups
except (VariableNotFoundError, AttributeError): except (VariableNotFoundError, AttributeError):
@ -2230,7 +2234,7 @@ class DirectoryProcessor:
return -1 return -1
return -2 return -2
def _make_current_template_var(self) -> NoReturn: def _make_current_template_var(self) -> None:
var_path = ['main', 'cl'] var_path = ['main', 'cl']
namespace = self.datavars_module namespace = self.datavars_module
@ -2251,7 +2255,7 @@ class DirectoryProcessor:
else: else:
namespace['current_template'] = "" namespace['current_template'] = ""
def process_template_directories(self) -> NoReturn: def process_template_directories(self) -> None:
'''Метод для обхода шаблонов, содержащихся в каталогах из '''Метод для обхода шаблонов, содержащихся в каталогах из
main.cl_template.path.''' main.cl_template.path.'''
# Режим заполнения очередей директорий пакетов, необходимых для более # Режим заполнения очередей директорий пакетов, необходимых для более
@ -2319,12 +2323,11 @@ class DirectoryProcessor:
PackageCreator.save_all() PackageCreator.save_all()
return self.template_executor.changed_files return self.template_executor.changed_files
def _run_template_from_base_directory( def _run_template_from_base_directory(self, template_names: List[str],
self, template_names: List[str], base_directory: str,
base_directory: str, package: Optional[Package] = None,
package: Optional[Package] = None, directory_tree: Optional[dict] = None
directory_tree: Optional[dict] = None ) -> None:
) -> NoReturn:
'''Метод для запуска шаблонов файлов находящихся в базовой директории. '''Метод для запуска шаблонов файлов находящихся в базовой директории.
''' '''
self.template_engine.change_directory(base_directory) self.template_engine.change_directory(base_directory)
@ -2380,6 +2383,7 @@ class DirectoryProcessor:
else: else:
template_package = package template_package = package
# Дефолтные значения разные для упрощения написания шаблонов.
if not parameters.run and not parameters.exec: if not parameters.run and not parameters.exec:
if not parameters.format: if not parameters.format:
parameters.set_parameter({'format': 'raw'}) parameters.set_parameter({'format': 'raw'})
@ -2396,7 +2400,7 @@ class DirectoryProcessor:
template_text=template_text, template_text=template_text,
package=template_package) package=template_package)
def _execute_handlers(self) -> NoReturn: def _execute_handlers(self) -> None:
'''Метод для запуска обработчиков добавленных в очередь обработчиков '''Метод для запуска обработчиков добавленных в очередь обработчиков
с помощью параметра notify.''' с помощью параметра notify.'''
self.output.set_info('Processing handlers...') self.output.set_info('Processing handlers...')
@ -2462,7 +2466,7 @@ class DirectoryProcessor:
FILE, handler_path, FILE, handler_path,
template_text=handler_text) template_text=handler_text)
def _merge_packages(self) -> NoReturn: def _merge_packages(self) -> None:
'''Метод для выполнения шаблонов относящихся к пакетам, указанным во '''Метод для выполнения шаблонов относящихся к пакетам, указанным во
всех встреченных значениях параметра merge.''' всех встреченных значениях параметра merge.'''
not_merged_packages = [] not_merged_packages = []
@ -2527,7 +2531,7 @@ class DirectoryProcessor:
else: else:
self.output.set_success('All packages are merged.') self.output.set_success('All packages are merged.')
def _run_exec_files(self) -> NoReturn: def _run_exec_files(self) -> None:
'''Метод для выполнения скриптов, полученных в результате обработки '''Метод для выполнения скриптов, полученных в результате обработки
шаблонов с параметром exec.''' шаблонов с параметром exec.'''
for exec_file_path, exec_info in\ for exec_file_path, exec_info in\
@ -2554,7 +2558,7 @@ class DirectoryProcessor:
current_target_path: str, current_target_path: str,
directory_parameters: ParametersContainer, directory_parameters: ParametersContainer,
directory_tree: Union[dict, DirectoryTree] = {}, directory_tree: Union[dict, DirectoryTree] = {},
package: Optional[Package] = None) -> NoReturn: package: Optional[Package] = None) -> None:
'''Метод для рекурсивного обхода директорий с шаблонами, а также, при '''Метод для рекурсивного обхода директорий с шаблонами, а также, при
необходимости, заполнения деревьев директорий шаблонов, с помощью необходимости, заполнения деревьев директорий шаблонов, с помощью
которых далее выполняются шаблоны пакетов из merge.''' которых далее выполняются шаблоны пакетов из merge.'''
@ -2805,7 +2809,6 @@ class DirectoryProcessor:
if self.fill_trees: if self.fill_trees:
directory_tree = {} directory_tree = {}
return
def _scan_directory(self, directory_path: str def _scan_directory(self, directory_path: str
) -> Tuple[List[str], List[str]]: ) -> Tuple[List[str], List[str]]:

@ -1,89 +0,0 @@
from pyparsing import Literal, Word, ZeroOrMore, Group, Dict, Optional,\
restOfLine, empty, printables, OneOrMore, oneOf, nums,\
lineno, line, col, Keyword, SkipTo, LineEnd, Combine
from enum import Enum
class CalculateIniParser:
'''Класс парсера calculate.ini файлов.'''
class Define(Enum):
assign = 0
append = 1
remove = 2
def __init__(self):
self.operations = {"=": self.Define.assign,
"+=": self.Define.append,
"-=": self.Define.remove}
lbrack = Literal("[")
rbrack = Literal("]")
# comma = Literal(",").suppress()
comment_symbol = Literal(';') | Literal('#')
# Define = self.Define
value_operation = (Literal("=") | Combine(Literal("+") + Literal("="))
| Combine(Literal("-") + Literal("=")))
comment = comment_symbol + Optional(restOfLine)
section_name = Word(printables+'\t', excludeChars='[]')
value_name = Word(printables+'\t', excludeChars='=-+')
# non_comma = Word(printables+'\t', excludeChars=',')
clear_section = lbrack.suppress() + Group(empty) + rbrack.suppress()
section_start = Group(OneOrMore(lbrack.suppress() + section_name
+ rbrack.suppress())
+ (clear_section | ~lbrack())
+ LineEnd().suppress())
# Если содержимое ini-файла не предваряется заголовком секции,
# значит эта строка ошибочна.
unexpected = Group(~section_start + SkipTo(LineEnd(),
include=True))("error")
unexpected.setParseAction(self._unexpected_token)
key_value = (~lbrack + value_name
+ value_operation + empty
+ restOfLine + LineEnd().suppress())
def strip_key_value(tokens):
tokens[0] = tokens[0].strip()
tokens[1] = tokens[1].strip()
key_value.setParseAction(strip_key_value)
self.ini_section_parser = (section_start
+ Group(ZeroOrMore(
Group(key_value | unexpected)))
| unexpected)
self.ini_section_parser.ignore(comment)
def _unexpected_token(self, string, location, tokens):
'''Метод вызываемый парсером, если обнаружена некорректная строка,
предназначен для получения некорректной строки и ее дальнейшего
разбора.'''
error_line = line(location, string).strip()
if error_line:
return (error_line, lineno(location, string),
col(location, string))
def parse(self, data):
for tokens, start, end in self.ini_section_parser.scanString(data):
if tokens.getName() == "error":
print('error tokens: {}'.format(tokens))
yield {'error': tokens}
section, defkeys = tokens
section_list = section.asList()
if section_list[-1] == []:
self.clear_section(section_list[:-1])
yield {'clear_section': (section_list[:-1], )}
self.start_section(section.asList())
for defkey in defkeys:
yield {'define_key': (section.asList(), defkey[0], defkey[2],
self.operations[defkey[1]])}

@ -1,14 +0,0 @@
{% for file_name, contents_values in document_dictionary.items() %}
{% if contents_values is mapping %}
{% set contents_values = (contents_values.values()|list) %}
{% else %}
{% set contents_values = (contents_values[0]|list) %}
{% set file_name = file_name[-1] %}
{% endif %}
{% if contents_values[0] == 'sym' %}
{{ contents_values[0] }} {{ file_name }} -> {{ contents_values[1:]|join(' ') }}
{% else %}
{{ contents_values[0] }} {{ file_name }}{% if contents_values[1:] %} {{ contents_values[1:]|join(' ') }}{% endif %}
{% endif %}
{% endfor %}

@ -1,22 +1,23 @@
import os import os
import re import re
from typing import Dict
from calculate.utils.files import read_file_lines from calculate.utils.files import read_file_lines
class ProfileWalker: class ProfileWalker:
'''Объект обходящий все директории профиля через parent файлы.''' '''Объект обходящий все директории профиля через parent файлы.'''
def __init__(self, filename, repositories): def __init__(self, filename: str, repositories: Dict[str, str]):
self.repositories = repositories self.repositories: Dict[str, str] = repositories
self.filename = filename self.filename: str = filename
self.re_reppath = re.compile("^({0})+:".format( self.re_reppath: re.Pattern = re.compile("^({0})+:".format(
"|".join(self.repositories.keys()))) "|".join(self.repositories.keys())))
def interpolate(self, path): def interpolate(self, path: str):
def subfunc(m): def subfunc(m):
return "{0}/profiles/".format(self.repositories.get(m.group(1))) return "{0}/profiles/".format(self.repositories.get(m.group(1)))
return self.re_reppath.sub(subfunc, path) return self.re_reppath.sub(subfunc, path)
def find(self, directory): def find(self, directory: str):
'''Метод для поиска по профилю всех файлов с именем, указанным в '''Метод для поиска по профилю всех файлов с именем, указанным в
self.filename.''' self.filename.'''
parent_file_path = os.path.join(directory, "parent") parent_file_path = os.path.join(directory, "parent")

@ -7,7 +7,6 @@ from collections import OrderedDict
from .files import read_file, read_link, join_paths, FilesError from .files import read_file, read_link, join_paths, FilesError
from typing import ( from typing import (
Generator, Generator,
NoReturn,
Optional, Optional,
Dict, Dict,
Tuple, Tuple,
@ -15,16 +14,6 @@ from typing import (
List, List,
Any Any
) )
from pyparsing import (
Literal,
Regex,
Word,
nums,
alphanums,
LineEnd,
SkipTo,
)
from jinja2 import PackageLoader, Environment
from calculate.utils.tools import Singleton from calculate.utils.tools import Singleton
import hashlib import hashlib
@ -59,6 +48,10 @@ class PackageNotFound(Exception):
class PackageCreator(type): class PackageCreator(type):
"""Метакласс для создания классов пакетов, напоминающий метакласс
Singleton. Следит за тем, чтобы для каждого пакета создавался только один
экземляр объекта Package."""
# TODO Перенести этот функционал в класс Package.
_instances: Dict["PackageAtomName", "Package"] = {} _instances: Dict["PackageAtomName", "Package"] = {}
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
@ -74,14 +67,14 @@ class PackageCreator(type):
return cls._instances[args[0]] return cls._instances[args[0]]
@classmethod @classmethod
def save_all(cls) -> NoReturn: def save_all(cls) -> None:
for atom, pkg in cls._instances.items(): for atom, pkg in cls._instances.items():
pkg.remove_empty_directories() pkg.remove_empty_directories()
pkg.write_contents_file() pkg.write_contents_file()
@classmethod @classmethod
def clear_instances(cls) -> NoReturn: def clear_instances(cls) -> None:
"""Метод для очистки текущего кэша пакетный объектов. """Метод для очистки текущего кэша пакетных объектов.
Необходим при тестировании.""" Необходим при тестировании."""
cls._instances.clear() cls._instances.clear()
@ -370,62 +363,6 @@ class ContentsParser(metaclass=Singleton):
return "\n".join(lines) return "\n".join(lines)
class OldContentsParser(metaclass=Singleton):
def __init__(self):
sym_keyword = Literal('sym')
dir_keyword = Literal('dir')
obj_keyword = Literal('obj')
symlink_arrow = Literal('->')
file_path = Regex(r'\S+')
time_value = Word(nums)
md5 = Word(alphanums)
sym_line = (sym_keyword('type') + file_path('path')
+ symlink_arrow.suppress() + file_path('target')
+ time_value('mtime') + LineEnd().suppress())
dir_line = (dir_keyword('type') + file_path('path')
+ LineEnd().suppress())
obj_line = (obj_keyword('type') + file_path('path')
+ md5('md5') + time_value('mtime') + LineEnd().suppress())
unexpected = SkipTo(LineEnd(), include=True)
self._parser = (dir_line | sym_line |
obj_line | unexpected('unexpected')
).setParseAction(self._parser_method)
def _parser_method(self, parse_result):
if parse_result.getName() == 'unexpected':
return [None]
result_dict = parse_result.asDict()
path = result_dict.pop('path')
value = ({path: result_dict})
return value
def parse(self, contents_text: str) -> OrderedDict:
output_dictionary = OrderedDict()
for tokens, start, end in self._parser.scanString(contents_text):
parse_result = tokens[0]
if parse_result is None:
continue
output_dictionary.update(parse_result)
return output_dictionary
def render(self, contents_dictionary: dict) -> str:
file_loader = PackageLoader('calculate', 'utils')
environment = Environment(loader=file_loader,
trim_blocks=True,
lstrip_blocks=True)
template = environment.get_template('contents_template')
text = template.render(document_dictionary=contents_dictionary)
return text
class PackageAtomName: class PackageAtomName:
'''Класс для хранения результата определения пакета. Для определения пакета '''Класс для хранения результата определения пакета. Для определения пакета
использует путь к его pkg директории.''' использует путь к его pkg директории.'''
@ -686,7 +623,7 @@ class PackageAtomParser:
return atom_dictionary return atom_dictionary
def _check_slot_value(self, pkg_path: str, def _check_slot_value(self, pkg_path: str,
atom_dictionary: dict) -> NoReturn: atom_dictionary: dict) -> None:
'''Метод для проверки полученного из параметра package значения slot. '''Метод для проверки полученного из параметра package значения slot.
''' '''
if atom_dictionary['slot']: if atom_dictionary['slot']:
@ -699,7 +636,7 @@ class PackageAtomParser:
errno=NOTEXIST) errno=NOTEXIST)
def _check_use_flags_value(self, pkg_path: str, def _check_use_flags_value(self, pkg_path: str,
atom_dictionary: dict) -> NoReturn: atom_dictionary: dict) -> None:
'''Метод для проверки полученных из параметра package значений '''Метод для проверки полученных из параметра package значений
use-флагов.''' use-флагов.'''
if atom_dictionary['use_flags']: if atom_dictionary['use_flags']:
@ -742,7 +679,7 @@ class PackageAtomParser:
yield path yield path
def _check_version(self, atom_dictionary: dict, pkg_version: Version def _check_version(self, atom_dictionary: dict, pkg_version: Version
) -> NoReturn: ) -> None:
condition = atom_dictionary['condition'] condition = atom_dictionary['condition']
if condition == '=': if condition == '=':
@ -769,6 +706,7 @@ class PackageAtomParser:
def get_file_package(self, file_path: str) -> PackageAtomName: def get_file_package(self, file_path: str) -> PackageAtomName:
'''Метод для определения пакета, которому принадлежит файл.''' '''Метод для определения пакета, которому принадлежит файл.'''
# Удаляем часть пути соответствующую chroot_path # Удаляем часть пути соответствующую chroot_path
# TODO предусмотреть кэширование результата.
if self.chroot_path != '/' and file_path.startswith(self.chroot_path): if self.chroot_path != '/' and file_path.startswith(self.chroot_path):
file_path = file_path[len(self.chroot_path):] file_path = file_path[len(self.chroot_path):]
@ -919,7 +857,7 @@ class Package(metaclass=PackageCreator):
else: else:
return False return False
def write_contents_file(self) -> NoReturn: def write_contents_file(self) -> None:
'''Метод для записи файла CONTENTS.''' '''Метод для записи файла CONTENTS.'''
with open(self.contents_file_path, 'w') as contents_file: with open(self.contents_file_path, 'w') as contents_file:
contents_text = self.render_contents_file() contents_text = self.render_contents_file()
@ -943,7 +881,7 @@ class Package(metaclass=PackageCreator):
return self.contents_dictionary[file_path]['type'] return self.contents_dictionary[file_path]['type']
return None return None
def sort_contents_dictionary(self) -> NoReturn: def sort_contents_dictionary(self) -> None:
'''Метод для сортировки словаря, полученного в результате разбора и '''Метод для сортировки словаря, полученного в результате разбора и
изменения CONTENTS-файла.''' изменения CONTENTS-файла.'''
tree = {} tree = {}
@ -980,7 +918,7 @@ class Package(metaclass=PackageCreator):
if level[part]: if level[part]:
yield from self._make_paths(part_path, level[part]) yield from self._make_paths(part_path, level[part])
def add_dir(self, file_name: str) -> NoReturn: def add_dir(self, file_name: str) -> None:
'''Метод для добавления в CONTENTS директорий.''' '''Метод для добавления в CONTENTS директорий.'''
file_name = self.remove_chroot_path(file_name) file_name = self.remove_chroot_path(file_name)
@ -992,7 +930,7 @@ class Package(metaclass=PackageCreator):
self.contents_dictionary[file_name] = contents_item self.contents_dictionary[file_name] = contents_item
def add_sym(self, file_name: str, target_path: Optional[str] = None, def add_sym(self, file_name: str, target_path: Optional[str] = None,
mtime: Optional[str] = None) -> NoReturn: mtime: Optional[str] = None) -> None:
'''Метод для добавления в CONTENTS символьных ссылок.''' '''Метод для добавления в CONTENTS символьных ссылок.'''
real_path = file_name real_path = file_name
@ -1018,7 +956,7 @@ class Package(metaclass=PackageCreator):
self.contents_dictionary[file_name] = contents_item self.contents_dictionary[file_name] = contents_item
def add_obj(self, file_name: str, file_md5: Optional[str] = None, def add_obj(self, file_name: str, file_md5: Optional[str] = None,
mtime: Optional[str] = None) -> NoReturn: mtime: Optional[str] = None) -> None:
'''Метод для добавления в CONTENTS обычных файлов как obj.''' '''Метод для добавления в CONTENTS обычных файлов как obj.'''
real_path = file_name real_path = file_name
file_name = self.remove_chroot_path(file_name) file_name = self.remove_chroot_path(file_name)
@ -1043,7 +981,7 @@ class Package(metaclass=PackageCreator):
'mtime': mtime}) 'mtime': mtime})
self.contents_dictionary[file_name] = contents_item self.contents_dictionary[file_name] = contents_item
def add_file(self, file_name: str) -> NoReturn: def add_file(self, file_name: str) -> None:
'''Метод для добавления в CONTENTS файла любого типа.''' '''Метод для добавления в CONTENTS файла любого типа.'''
if file_name != '/': if file_name != '/':
real_path = file_name real_path = file_name
@ -1197,7 +1135,7 @@ class Package(metaclass=PackageCreator):
return '<Package: {}/{}>'.format(self.package_name.category, return '<Package: {}/{}>'.format(self.package_name.category,
self.package_name.fullname) self.package_name.fullname)
# def __del__(self) -> NoReturn: # def __del__(self) -> None:
# if self.autosave and os.path.exists(self.contents_file_path): # if self.autosave and os.path.exists(self.contents_file_path):
# self.remove_empty_directories() # self.remove_empty_directories()
# self.write_contents_file() # self.write_contents_file()

@ -1,6 +1,7 @@
from calculate.utils.files import grep_file from calculate.utils.files import grep_file
import os import os
class SystemType: class SystemType:
""" """
Тип контейнера текущей системы Тип контейнера текущей системы
@ -21,12 +22,12 @@ class SystemType:
if grep_file("/proc/cpuinfo", "UML"): if grep_file("/proc/cpuinfo", "UML"):
return cls.Uml return cls.Uml
elif grep_file("/proc/self/status", elif grep_file("/proc/self/status",
"(s_context|VxID):\s*[1-9]"): r"(s_context|VxID):\s*[1-9]"):
return cls.VServer return cls.VServer
elif (os.path.exists("/proc/vz/veinfo") elif (os.path.exists("/proc/vz/veinfo")
and not os.path.exists("/proc/vz/version")): and not os.path.exists("/proc/vz/version")):
return cls.OpenVZ return cls.OpenVZ
elif grep_file("/proc/self/status", "envID:\s*[1-9]"): elif grep_file("/proc/self/status", r"envID:\s*[1-9]"):
return cls.OpenVZ return cls.OpenVZ
elif grep_file("/proc/1/environ", "container=lxc"): elif grep_file("/proc/1/environ", "container=lxc"):
return cls.LXC return cls.LXC

@ -117,12 +117,15 @@ def get_traceback_caller(exception_type, exception_object,
if module_name.endswith('.py'): if module_name.endswith('.py'):
module_name = module_name[:-3] module_name = module_name[:-3]
full_module_name = [module_name] full_module_name = [module_name]
while (module_path and module_path != '/' and not while (module_path and module_path != '/' and not
module_path.endswith('site-packages')): module_path.endswith('site-packages')):
module_path, package_name = os.path.split(module_path) module_path, package_name = os.path.split(module_path)
full_module_name.insert(0, package_name) full_module_name.insert(0, package_name)
if module_path.endswith('site-packages'): if module_path.endswith('site-packages'):
module_name = '.'.join(full_module_name) module_name = '.'.join(full_module_name)
return module_name, line_number return module_name, line_number

@ -1,13 +1,15 @@
# vim: fileencoding=utf-8 # vim: fileencoding=utf-8
# #
import re import re
import ast # import ast
import dis import dis
from contextlib import contextmanager from contextlib import contextmanager
from inspect import signature, getsource # from inspect import signature, getsource
from types import FunctionType, LambdaType from inspect import signature
# from types import FunctionType, LambdaType
from types import FunctionType
from calculate.utils.tools import Singleton from calculate.utils.tools import Singleton
from typing import List, Any, Union, Generator, Callable from typing import List, Any, Union, Generator, Callable, Optional
class DependenceError(Exception): class DependenceError(Exception):
@ -436,7 +438,7 @@ class VariableWrapper:
class DependenceSource: class DependenceSource:
'''Класс зависимости как источника значения переменной.''' '''Класс зависимости как источника значения переменной.'''
def __init__(self, variables: tuple, def __init__(self, variables: tuple,
depend: Union[Callable, None] = None): depend: Optional[Callable] = None):
self._args: Union[tuple, list] = variables self._args: Union[tuple, list] = variables
self.depend_function: Union[Callable, None] = depend self.depend_function: Union[Callable, None] = depend
self._subscriptions: set = set() self._subscriptions: set = set()
@ -736,7 +738,7 @@ class VariableNode:
subscriber._invalidate() subscriber._invalidate()
@contextmanager @contextmanager
def _start_calculate(self): def _start_calculate(self) -> Generator["VariableNode", None, None]:
'''Менеджер контекста устанавливающий флаг, указывающий, что данная '''Менеджер контекста устанавливающий флаг, указывающий, что данная
переменная в состоянии расчета. В данном случае необходима только для переменная в состоянии расчета. В данном случае необходима только для
того, чтобы при получении значения параметра внутри метода для расчета того, чтобы при получении значения параметра внутри метода для расчета
@ -782,7 +784,7 @@ class VariableNode:
class NamespaceNode: class NamespaceNode:
'''Класс ноды соответствующей пространству имен в дереве переменных.''' '''Класс ноды соответствующей пространству имен в дереве переменных.'''
def __init__(self, name: str = '', def __init__(self, name: str = '',
parent: Union["NamespaceNode", None] = None): parent: Optional["NamespaceNode"] = None):
self._name = name self._name = name
self._variables = dict() self._variables = dict()
self._namespaces = dict() self._namespaces = dict()
@ -943,9 +945,10 @@ class FormatAPI(metaclass=Singleton):
def __call__(self, string: str) -> "Dependence": def __call__(self, string: str) -> "Dependence":
vars_list = [] vars_list = []
def subfunc(matchobj): def subfunc(matchobj: re.Match):
vars_list.append(matchobj.group(0)[1:-1].strip()) vars_list.append(matchobj.group(0)[1:-1].strip())
return '{}' return '{}'
format_string = self.pattern.sub(subfunc, string) format_string = self.pattern.sub(subfunc, string)
def depend_function(*args): def depend_function(*args):
@ -1013,7 +1016,7 @@ class NamespaceAPI(metaclass=Singleton):
'''Класс для создания пространств имен при задании переменных через '''Класс для создания пространств имен при задании переменных через
python-скрипты.''' python-скрипты.'''
def __init__(self, var_fabric: VariableAPI, def __init__(self, var_fabric: VariableAPI,
dependence_fabric: DependenceAPI(), dependence_fabric: DependenceAPI,
datavars_root=NamespaceNode('<root>')): datavars_root=NamespaceNode('<root>')):
self._datavars = datavars_root self._datavars = datavars_root
self.current_namespace = self._datavars self.current_namespace = self._datavars
@ -1028,7 +1031,7 @@ class NamespaceAPI(metaclass=Singleton):
self._dependence_fabric.datavars_root = self._datavars self._dependence_fabric.datavars_root = self._datavars
@property @property
def datavars(self): def datavars(self) -> NamespaceNode:
'''Метод для получения корневого пространства имен, через которое далее '''Метод для получения корневого пространства имен, через которое далее
можно получить доступ к переменным.''' можно получить доступ к переменным.'''
return self._datavars return self._datavars
@ -1058,7 +1061,8 @@ class NamespaceAPI(metaclass=Singleton):
self._dependence_fabric.current_namespace = namespace self._dependence_fabric.current_namespace = namespace
@contextmanager @contextmanager
def __call__(self, namespace_name: str): def __call__(self, namespace_name: str
) -> Generator["NamespaceAPI", None, None]:
'''Метод для создания пространств имен с помощью with.''' '''Метод для создания пространств имен с помощью with.'''
if namespace_name not in self.current_namespace._namespaces: if namespace_name not in self.current_namespace._namespaces:
namespace = NamespaceNode(namespace_name, namespace = NamespaceNode(namespace_name,

@ -5,6 +5,7 @@ import logging
import importlib import importlib
import importlib.util import importlib.util
from jinja2 import Environment, PackageLoader from jinja2 import Environment, PackageLoader
from typing import Dict, Optional, Any, List, Generator
from calculate.variables.datavars import ( from calculate.variables.datavars import (
NamespaceNode, NamespaceNode,
VariableNode, VariableNode,
@ -22,11 +23,12 @@ from calculate.utils.gentoo import ProfileWalker
from calculate.utils.files import read_file, FilesError from calculate.utils.files import read_file, FilesError
from calculate.utils.tools import Singleton from calculate.utils.tools import Singleton
from pyparsing import ( from pyparsing import (
ParseResults,
Literal, Literal,
Word, Word,
ZeroOrMore, ZeroOrMore,
Group, Group,
Optional, Optional as OptionalPattern,
restOfLine, restOfLine,
empty, empty,
printables, printables,
@ -65,14 +67,12 @@ class CalculateIniParser(metaclass=Singleton):
lbrack = Literal("[") lbrack = Literal("[")
rbrack = Literal("]") rbrack = Literal("]")
# comma = Literal(",").suppress()
comment_symbol = Literal(';') | Literal('#') comment_symbol = Literal(';') | Literal('#')
# Define = self.Define
value_operation = (Literal("=") | Combine(Literal("+") + Literal("=")) value_operation = (Literal("=") | Combine(Literal("+") + Literal("="))
| Combine(Literal("-") + Literal("="))) | Combine(Literal("-") + Literal("=")))
comment = comment_symbol + Optional(restOfLine) comment = comment_symbol + OptionalPattern(restOfLine)
section_name = (lbrack.suppress() + (~Word(nums) section_name = (lbrack.suppress() + (~Word(nums)
+ Word(printables+'\t', + Word(printables+'\t',
@ -81,7 +81,6 @@ class CalculateIniParser(metaclass=Singleton):
value_name = Word(printables+'\t', excludeChars='=-+') value_name = Word(printables+'\t', excludeChars='=-+')
# non_comma = Word(printables+'\t', excludeChars=',')
clear_section = lbrack.suppress() + Group(empty) + rbrack.suppress() clear_section = lbrack.suppress() + Group(empty) + rbrack.suppress()
row_index = lbrack.suppress() + Word(nums) + rbrack.suppress() row_index = lbrack.suppress() + Word(nums) + rbrack.suppress()
@ -93,7 +92,8 @@ class CalculateIniParser(metaclass=Singleton):
+ (row_index | clear_section | ~lbrack) + (row_index | clear_section | ~lbrack)
+ LineEnd().suppress()) + LineEnd().suppress())
def add_lineno(string, location, tokens): def add_lineno(string: str, location: int,
tokens: ParseResults) -> None:
tokens.append(lineno(location, string)) tokens.append(lineno(location, string))
section_start = (namespace_start('namespace') | section_start = (namespace_start('namespace') |
@ -110,7 +110,8 @@ class CalculateIniParser(metaclass=Singleton):
+ value_operation + empty + value_operation + empty
+ restOfLine + LineEnd().suppress()) + restOfLine + LineEnd().suppress())
def process_key_value(string, location, tokens): def process_key_value(string: str, location: int,
tokens: ParseResults) -> None:
tokens[0] = tokens[0].strip() tokens[0] = tokens[0].strip()
tokens[1] = tokens[1].strip() tokens[1] = tokens[1].strip()
tokens.append(lineno(location, string)) tokens.append(lineno(location, string))
@ -124,7 +125,7 @@ class CalculateIniParser(metaclass=Singleton):
| unexpected) | unexpected)
self.ini_section_parser.ignore(comment) self.ini_section_parser.ignore(comment)
def parse(self, data: str): def parse(self, data: str) -> Generator[Dict[str, tuple], None, None]:
for tokens, start, end in self.ini_section_parser.scanString(data): for tokens, start, end in self.ini_section_parser.scanString(data):
if tokens.getName() == "error": if tokens.getName() == "error":
if tokens[1].strip(): if tokens[1].strip():
@ -161,7 +162,8 @@ class CalculateIniParser(metaclass=Singleton):
yield {'start_table': (table_list, table_values, yield {'start_table': (table_list, table_values,
section_lineno)} section_lineno)}
def _unexpected_token(self, string, location, tokens): def _unexpected_token(self, string: str, location: int,
tokens: ParseResults) -> list:
'''Метод вызываемый парсером, если обнаружена некорректная строка, '''Метод вызываемый парсером, если обнаружена некорректная строка,
предназначен для получения некорректной строки и ее дальнейшего предназначен для получения некорректной строки и ее дальнейшего
разбора.''' разбора.'''
@ -173,14 +175,14 @@ class NamespaceIniFiller:
из calculate.ini файла.''' из calculate.ini файла.'''
available_sections = {'custom'} available_sections = {'custom'}
def __init__(self, restrict_creation=True): def __init__(self, restrict_creation: bool = True):
self.ini_parser = CalculateIniParser() self.ini_parser = CalculateIniParser()
self._errors = [] self._errors = []
# Флаги, определяющие возможность создания новых переменных и новых # Флаги, определяющие возможность создания новых переменных и новых
# пространств имен в данном пространстве имен. # пространств имен в данном пространстве имен.
self.restricted = restrict_creation self.restricted: bool = restrict_creation
self.modify_only = False self.modify_only: bool = False
def fill(self, namespace: NamespaceNode, ini_file_text: str) -> None: def fill(self, namespace: NamespaceNode, ini_file_text: str) -> None:
'''Метод для разбора calculate.ini файла и добавления всех его '''Метод для разбора calculate.ini файла и добавления всех его
@ -192,11 +194,12 @@ class NamespaceIniFiller:
for parsed_line in self.ini_parser.parse(ini_file_text): for parsed_line in self.ini_parser.parse(ini_file_text):
self._line_processor(**parsed_line) self._line_processor(**parsed_line)
def _line_processor(self, start_section=None, def _line_processor(self, start_section: Optional[tuple] = None,
clear_section=None, clear_section: Optional[tuple] = None,
start_table=None, start_table: Optional[tuple] = None,
define_key=None, define_key: Optional[tuple] = None,
error=None, **kwargs): error: Optional[tuple] = None,
**kwargs) -> None:
'''Метод вызывающий обработку токенов, выдаваемых парсером в '''Метод вызывающий обработку токенов, выдаваемых парсером в
зависимости от их типа.''' зависимости от их типа.'''
if start_section is not None: if start_section is not None:
@ -210,7 +213,7 @@ class NamespaceIniFiller:
elif error is not None: elif error is not None:
self._set_error(error[0], 'SyntaxError', error[1]) self._set_error(error[0], 'SyntaxError', error[1])
def start_section(self, sections: str, lineno) -> None: def start_section(self, sections: str, lineno: int) -> None:
'''Метод для получения доступа и создания пространств имен.''' '''Метод для получения доступа и создания пространств имен.'''
if self.restricted: if self.restricted:
self.modify_only = sections[0] not in self.available_sections self.modify_only = sections[0] not in self.available_sections
@ -248,7 +251,7 @@ class NamespaceIniFiller:
self.current_namespace = self.current_namespace.\ self.current_namespace = self.current_namespace.\
_namespaces[section] _namespaces[section]
def clear_section(self, sections: list, lineno) -> None: def clear_section(self, sections: list, lineno: int) -> None:
'''Метод для очистки пространства имен.''' '''Метод для очистки пространства имен.'''
if self.restricted: if self.restricted:
self.modify_only = sections[0] not in self.available_sections self.modify_only = sections[0] not in self.available_sections
@ -291,7 +294,7 @@ class NamespaceIniFiller:
" namespace.".format(current_namespace. " namespace.".format(current_namespace.
get_fullname())) get_fullname()))
def start_table(self, sections: str, row, lineno) -> None: def start_table(self, sections: str, row: dict, lineno: int) -> None:
'''Метод для создания и модификации таблиц.''' '''Метод для создания и модификации таблиц.'''
if self.restricted: if self.restricted:
self.modify_only = sections[0] not in self.available_sections self.modify_only = sections[0] not in self.available_sections
@ -335,7 +338,8 @@ class NamespaceIniFiller:
table.add_row(row) table.add_row(row)
table_variable.source = table table_variable.source = table
def define_key(self, key: str, value: str, optype, lineno) -> None: def define_key(self, key: str, value: str, optype: Define,
lineno: int) -> None:
'''Метод для создания и модификации переменных.''' '''Метод для создания и модификации переменных.'''
if self.current_namespace is None: if self.current_namespace is None:
return return
@ -360,7 +364,7 @@ class NamespaceIniFiller:
else: else:
self.remove_value(key, value, lineno) self.remove_value(key, value, lineno)
def change_value(self, key: str, value: str, lineno) -> None: def change_value(self, key: str, value: str, lineno: int) -> None:
'''Метод для изменения значения переменной.''' '''Метод для изменения значения переменной.'''
variable = self.current_namespace[key] variable = self.current_namespace[key]
if variable.readonly: if variable.readonly:
@ -370,7 +374,7 @@ class NamespaceIniFiller:
return return
variable.source = value variable.source = value
def define_variable(self, key: str, value: str, lineno) -> None: def define_variable(self, key: str, value: str, lineno: int) -> None:
'''Метод для создания переменных в calculate.ini файле.''' '''Метод для создания переменных в calculate.ini файле.'''
if not self.modify_only: if not self.modify_only:
VariableNode(key, self.current_namespace, variable_type=IniType, VariableNode(key, self.current_namespace, variable_type=IniType,
@ -382,7 +386,7 @@ class NamespaceIniFiller:
self.current_namespace.get_fullname(), self.current_namespace.get_fullname(),
key)) key))
def append_value(self, key: str, value: str, lineno) -> None: def append_value(self, key: str, value: str, lineno: int) -> None:
'''Метод выполняющий действия возложенные на оператор +=.''' '''Метод выполняющий действия возложенные на оператор +=.'''
variable = self.current_namespace[key] variable = self.current_namespace[key]
if variable.readonly: if variable.readonly:
@ -416,7 +420,7 @@ class NamespaceIniFiller:
variable.source = variable_value variable.source = variable_value
def remove_value(self, key: str, value: str, lineno) -> None: def remove_value(self, key: str, value: str, lineno: int) -> None:
'''Метод выполняющий действия возложенные на оператор -=.''' '''Метод выполняющий действия возложенные на оператор -=.'''
variable = self.current_namespace[key] variable = self.current_namespace[key]
if variable.readonly: if variable.readonly:
@ -450,7 +454,8 @@ class NamespaceIniFiller:
variable.source = variable_value variable.source = variable_value
def update_hash(self, key: str, value: str, optype, lineno): def update_hash(self, key: str, value: str, optype: Define,
lineno: int) -> None:
'''Метод для изменения переменных хэшей через calculate.ini.''' '''Метод для изменения переменных хэшей через calculate.ini.'''
if self.current_namespace.readonly: if self.current_namespace.readonly:
self._set_error(lineno, 'VariableError', self._set_error(lineno, 'VariableError',
@ -496,12 +501,12 @@ class NamespaceIniFiller:
self.current_namespace.source = hash_to_update self.current_namespace.source = hash_to_update
def _set_error(self, lineno, error_type, line): def _set_error(self, lineno: int, error_type: str, line: str) -> None:
'''Метод для добавления ошибки в лог.''' '''Метод для добавления ошибки в лог.'''
self._errors.append("{}:{}: {}".format(error_type, lineno, line)) self._errors.append("{}:{}: {}".format(error_type, lineno, line))
@property @property
def errors(self): def errors(self) -> List[str]:
errors = self._errors errors = self._errors
self._errors = [] self._errors = []
return errors return errors
@ -511,7 +516,8 @@ class VariableLoader:
'''Класс загрузчика переменных из python-файлов и из ini-файлов.''' '''Класс загрузчика переменных из python-файлов и из ini-файлов.'''
ini_basename = "calculate.ini" ini_basename = "calculate.ini"
def __init__(self, datavars, variables_path, repository_map=None): def __init__(self, datavars: "Datavars", variables_path: str,
repository_map: Optional[Dict[str, str]] = None):
self.datavars = datavars self.datavars = datavars
self.logger = datavars.logger self.logger = datavars.logger
self.ini_filler = NamespaceIniFiller() self.ini_filler = NamespaceIniFiller()
@ -533,7 +539,7 @@ class VariableLoader:
self.datavars.root.add_namespace(package_namespace) self.datavars.root.add_namespace(package_namespace)
self._fill_from_package(package_namespace, directory_path, package) self._fill_from_package(package_namespace, directory_path, package)
def load_from_profile(self): def load_from_profile(self) -> None:
'''Метод для загрузки переменных из calculate.ini профиля.''' '''Метод для загрузки переменных из calculate.ini профиля.'''
# Проверяем наличие таблицы репозиториев в переменных. # Проверяем наличие таблицы репозиториев в переменных.
if self.repository_map == {}: if self.repository_map == {}:
@ -566,7 +572,7 @@ class VariableLoader:
profile_path)) profile_path))
self._fill_from_profile_ini(profile_path) self._fill_from_profile_ini(profile_path)
def load_user_variables(self): def load_user_variables(self) -> None:
'''Метод для загрузки переменных из calculate.ini указанных в '''Метод для загрузки переменных из calculate.ini указанных в
переменных env_order и env_path.''' переменных env_order и env_path.'''
try: try:
@ -625,7 +631,7 @@ class VariableLoader:
'{}.{}'.format(package, '{}.{}'.format(package,
directory_node.name)) directory_node.name))
def _fill_from_profile_ini(self, profile_path): def _fill_from_profile_ini(self, profile_path: str) -> None:
'''Метод для зaполнения переменных из ini-файла.''' '''Метод для зaполнения переменных из ini-файла.'''
profile_walker = ProfileWalker(self.ini_basename, profile_walker = ProfileWalker(self.ini_basename,
self.repository_map) self.repository_map)
@ -637,13 +643,13 @@ class VariableLoader:
self.logger.error("Can not load profile variables from" self.logger.error("Can not load profile variables from"
" unexisting file: {}".format(file_path)) " unexisting file: {}".format(file_path))
def _get_repository_map(self, datavars): def _get_repository_map(self, datavars: "Datavars") -> Dict[str, str]:
'''Метод для получения из переменной словаря с репозиториями и путями '''Метод для получения из переменной словаря с репозиториями и путями
к ним.''' к ним.'''
return {repo['name']: repo['path'] return {repo['name']: repo['path']
for repo in datavars.os.gentoo.repositories} for repo in datavars.os.gentoo.repositories}
def fill_from_custom_ini(self, file_path: str): def fill_from_custom_ini(self, file_path: str) -> None:
'''Метод для заполнения переменных из конкретного указанного файла.''' '''Метод для заполнения переменных из конкретного указанного файла.'''
if os.path.exists(file_path): if os.path.exists(file_path):
ini_file_text = read_file(file_path) ini_file_text = read_file(file_path)
@ -661,7 +667,7 @@ class VariableLoader:
" not exist.".format(file_path)) " not exist.".format(file_path))
@contextmanager @contextmanager
def test(self, file_name, namespace): def test(self, file_name: str, namespace: NamespaceNode):
'''Контекстный менеджер для тестирования.''' '''Контекстный менеджер для тестирования.'''
print('IMPORT: {}.{}'.format(namespace.get_fullname(), file_name)) print('IMPORT: {}.{}'.format(namespace.get_fullname(), file_name))
try: try:
@ -673,7 +679,7 @@ class VariableLoader:
class CalculateIniSaver: class CalculateIniSaver:
'''Класс для сохранения значений переменных в указанные ini-файлы.''' '''Класс для сохранения значений переменных в указанные ini-файлы.'''
def __init__(self, ini_parser=None): def __init__(self):
self.ini_parser = CalculateIniParser() self.ini_parser = CalculateIniParser()
self.operations = {Define.assign: '=', self.operations = {Define.assign: '=',
Define.append: '+=', Define.append: '+=',
@ -683,8 +689,10 @@ class CalculateIniSaver:
environment = Environment(loader=file_loader) environment = Environment(loader=file_loader)
self.ini_template = environment.get_template('ini_template') self.ini_template = environment.get_template('ini_template')
def save_to_ini(self, target_path, variables_to_save): def save_to_ini(self, target_path: str,
'''Метод для сохранения переменных в указанный ini-файл.''' variables_to_save: Dict[str, Dict[str, Any]]) -> None:
'''Метод для сохранения значений переменных, переданных в словаре, в
указанный ini-файл.'''
ini_file_text = read_file(target_path) ini_file_text = read_file(target_path)
ini_dictionary = self._parse_ini(ini_file_text) ini_dictionary = self._parse_ini(ini_file_text)
@ -698,7 +706,7 @@ class CalculateIniSaver:
with open(target_path, 'w') as ini_file: with open(target_path, 'w') as ini_file:
ini_file.write(ini_file_text) ini_file.write(ini_file_text)
def _parse_ini(self, ini_file_text): def _parse_ini(self, ini_file_text: str) -> Dict[str, Dict[str, str]]:
'''Метод для разбора текста ini-файла в словарь, в который далее будут '''Метод для разбора текста ini-файла в словарь, в который далее будут
добавляться измененные переменные.''' добавляться измененные переменные.'''
current_namespace = None current_namespace = None
@ -725,7 +733,7 @@ class CalculateIniSaver:
line_content[1])}) line_content[1])})
return ini_dictionary return ini_dictionary
def _get_ini_text(self, ini_dictionary): def _get_ini_text(self, ini_dictionary: Dict[str, Dict[str, Any]]) -> str:
'''Метод для получения текста ini файла, полученного в результате '''Метод для получения текста ini файла, полученного в результате
наложения изменений из тегов save в шаблонах.''' наложения изменений из тегов save в шаблонах.'''
ini_text = self.ini_template.render(ini_dictionary=ini_dictionary) ini_text = self.ini_template.render(ini_dictionary=ini_dictionary)
@ -734,9 +742,10 @@ class CalculateIniSaver:
class Datavars: class Datavars:
'''Класс для хранения переменных и управления ими.''' '''Класс для хранения переменных и управления ими.'''
def __init__(self, variables_path='calculate/vars', repository_map=None, def __init__(self, variables_path: str = 'calculate/vars',
logger=None): repository_map: Optional[Dict[str, str]] = None,
self._variables_path = variables_path logger: Optional[logging.Logger] = None):
self._variables_path: str = variables_path
self._available_packages = self._get_available_packages() self._available_packages = self._get_available_packages()
if logger is not None: if logger is not None:
self.logger = logger self.logger = logger
@ -765,15 +774,16 @@ class Datavars:
except VariableNotFoundError: except VariableNotFoundError:
self.variables_to_save = dict() self.variables_to_save = dict()
def reset(self): def reset(self) -> None:
'''Метод для сброса модуля переменных.''' '''Метод для сброса модуля переменных.'''
self.root.clear() self.root.clear()
self.root = NamespaceNode('<root>') self.root = NamespaceNode('<root>')
self._available_packages.clear() self._available_packages.clear()
self._available_packages = self._get_available_packages() self._available_packages: Dict[str,
str] = self._get_available_packages()
Namespace.set_datavars(self) Namespace.set_datavars(self)
def _get_available_packages(self) -> dict: def _get_available_packages(self) -> Dict[str, str]:
'''Метод для получения словаря с имеющимися пакетами переменных '''Метод для получения словаря с имеющимися пакетами переменных
и путями к ним.''' и путями к ним.'''
variables_path = os.path.join( variables_path = os.path.join(
@ -788,7 +798,7 @@ class Datavars:
available_packages.update({file_name: file_path}) available_packages.update({file_name: file_path})
return available_packages return available_packages
def _load_package(self, package_name): def _load_package(self, package_name: str) -> None:
'''Метод для загрузки переменных содержащихся в указанном пакете.''' '''Метод для загрузки переменных содержащихся в указанном пакете.'''
self.logger.info("Loading datavars package '{}'".format(package_name)) self.logger.info("Loading datavars package '{}'".format(package_name))
try: try:
@ -798,12 +808,12 @@ class Datavars:
format(error)) format(error))
@property @property
def available_packages(self): def available_packages(self) -> set:
packages = set(self._available_packages) packages = set(self._available_packages)
packages.update({'custom'}) packages.update({'custom'})
return packages return packages
def __getattr__(self, package_name: str): def __getattr__(self, package_name: str) -> NamespaceNode:
'''Метод возвращает ноду пространства имен, соответствующего искомому '''Метод возвращает ноду пространства имен, соответствующего искомому
пакету.''' пакету.'''
if package_name in self.root._namespaces: if package_name in self.root._namespaces:
@ -819,7 +829,7 @@ class Datavars:
self._load_package(package_name) self._load_package(package_name)
return self.root[package_name] return self.root[package_name]
def __getitem__(self, package_name: str) -> None: def __getitem__(self, package_name: str) -> NamespaceNode:
'''Метод возвращает ноду пространства имен, соответствующего искомому '''Метод возвращает ноду пространства имен, соответствующего искомому
пакету.''' пакету.'''
if package_name in self.root: if package_name in self.root:
@ -838,7 +848,7 @@ class Datavars:
self._load_package(package_name) self._load_package(package_name)
return self.root[package_name] return self.root[package_name]
def __contains__(self, package_name): def __contains__(self, package_name: str) -> bool:
if package_name in self.root._namespaces: if package_name in self.root._namespaces:
return True return True
elif package_name == 'custom': elif package_name == 'custom':
@ -855,10 +865,10 @@ class Datavars:
self._load_package(package_name) self._load_package(package_name)
return True return True
def add_namespace(self, namespace_node): def add_namespace(self, namespace_node: NamespaceNode) -> None:
self.root.add_namespace(namespace_node) self.root.add_namespace(namespace_node)
def create_tasks(self): def create_tasks(self) -> None:
'''Метод для создания всех необходимых пространств имен для работы '''Метод для создания всех необходимых пространств имен для работы
задач.''' задач.'''
tasks = NamespaceNode('tasks') tasks = NamespaceNode('tasks')
@ -870,10 +880,10 @@ class Datavars:
env.add_namespace('loop') env.add_namespace('loop')
@property @property
def _namespaces(self): def _namespaces(self) -> NamespaceNode:
return self.root._namespaces return self.root._namespaces
def save_variables(self): def save_variables(self) -> None:
'''Метод для сохранения значений переменных в calculate.ini файлах.''' '''Метод для сохранения значений переменных в calculate.ini файлах.'''
target_paths = self.main.cl.system.env_path target_paths = self.main.cl.system.env_path
saver = CalculateIniSaver() saver = CalculateIniSaver()

@ -16,7 +16,8 @@ Variable('cl_root_path', type=StringType.readonly, source='/')
Variable('cl_template_path', type=ListType.readonly, Variable('cl_template_path', type=ListType.readonly,
source=['./tests/templates/testfiles/test_runner']) source=['./tests/templates/testfiles/test_runner'])
Variable('cl_ignore_files', type=ListType.readonly, source=['*.swp']) Variable('cl_ignore_files', type=ListType.readonly,
source=['*.swp', '.git'])
Variable('cl_config_path', type=StringType.readonly, Variable('cl_config_path', type=StringType.readonly,
source='/var/lib/calculate/config') source='/var/lib/calculate/config')
@ -30,8 +31,5 @@ Variable('cl_exec_dir_path', type=StringType.readonly,
Variable('cl_resolutions', type=ListType.readonly, Variable('cl_resolutions', type=ListType.readonly,
source=["1680x1050", "1024x768"]) source=["1680x1050", "1024x768"])
Variable('cl_resolutions', type=ListType.readonly,
source=["1680x1050", "1024x768"])
Variable('cl_image_formats', type=ListType.readonly, Variable('cl_image_formats', type=ListType.readonly,
source=["GIF", "JPG", "JPEG", "PNG", "GFXBOOT"]) source=["GIF", "JPG", "JPEG", "PNG", "GFXBOOT"])

@ -107,6 +107,7 @@ def get_isoscan_fullpath(base_path, filename):
def import_variables(): def import_variables():
Variable("chroot_path", type=StringType, Variable("chroot_path", type=StringType,
source=Calculate(lambda x: x.value, "main.cl_chroot_path")) source=Calculate(lambda x: x.value, "main.cl_chroot_path"))
Variable("root_path", type=StringType, Variable("root_path", type=StringType,
source=Calculate(lambda x: x.value, "main.cl_root_path")) source=Calculate(lambda x: x.value, "main.cl_root_path"))

@ -92,16 +92,21 @@ def import_variables():
Variable("shortname", type=StringType, Variable("shortname", type=StringType,
source=Calculate(detect_other_shortname, source=Calculate(detect_other_shortname,
"main.cl.chroot_path")) "main.cl.chroot_path"))
Variable("name", type=StringType, Variable("name", type=StringType,
source=Calculate(dict_value, dictLinuxName, source=Calculate(dict_value, dictLinuxName,
".shortname", Static("Linux"))) ".shortname", Static("Linux")))
Variable("subname", type=StringType, Variable("subname", type=StringType,
source=Calculate(dict_value, dictLinuxSubName, source=Calculate(dict_value, dictLinuxSubName,
".shortname", Static(""))) ".shortname", Static("")))
Variable("system", type=StringType, Variable("system", type=StringType,
source=Calculate(dict_value, dictNameSystem, source=Calculate(dict_value, dictNameSystem,
".shortname", Static(""))) ".shortname", Static("")))
Variable("ver", type=StringType, Variable("ver", type=StringType,
source=Calculate(get_linux_version, "main.cl.chroot_path", source=Calculate(get_linux_version, "main.cl.chroot_path",
"main.os.gentoo.make_profile")) "main.os.gentoo.make_profile"))
Variable("build", type=StringType, source="") Variable("build", type=StringType, source="")

1339
output

File diff suppressed because it is too large Load Diff

@ -46,7 +46,9 @@ markers =
scripts: marker for testing of the scripts. scripts: marker for testing of the scripts.
commands: marker for testing of the commands. commands: marker for testing of the commands.
server: marker for testing of the server. server: marker for testing of the server.
responses: marker for testing of the responses.
chroot: marker for testing running by chroot chroot: marker for testing running by chroot
needs_root: marker for tests that needs root rights. needs_root: marker for tests that needs root rights.

@ -1,6 +1,9 @@
from calculate.server.server import Server import os
os.environ["TESTING"] = "True"
import uvicorn
from calculate.server.server import app
if __name__ == "__main__": if __name__ == "__main__":
server = Server() uvicorn.run(app, host="127.0.0.1", port=5000, log_level="info")
server.run()

@ -1,7 +1,10 @@
#! /usr/bin/python3 #! /usr/bin/python3
import argparse import argparse
from calculate.templates.template_processor import DirectoryProcessor from calculate.templates.template_processor import DirectoryProcessor
from calculate.variables.loader import Datavars from calculate.variables.loader import Datavars
from calculate.utils.io_module import IOModule from calculate.utils.io_module import IOModule
from calculate.utils.package import NonePackage from calculate.utils.package import NonePackage
from calculate.utils.tools import flat_iterable from calculate.utils.tools import flat_iterable

@ -181,7 +181,7 @@ class TestParameters:
full='Test parameters are needed for tests.'), full='Test parameters are needed for tests.'),
shortname='t').bind('os.linux.fullname', shortname='t').bind('os.linux.fullname',
'os.linux.shortname') 'os.linux.shortname')
.to_set('os.linux.subname')) .set_to('os.linux.subname'))
assert PARAMS['test'].value == 'Calculate Linux Desktop -> CLD' assert PARAMS['test'].value == 'Calculate Linux Desktop -> CLD'
datavars.os.linux['fullname'].set('Gentoo Linux') datavars.os.linux['fullname'].set('Gentoo Linux')
@ -189,7 +189,7 @@ class TestParameters:
datavars.os.linux['shortname'].set('GL') datavars.os.linux['shortname'].set('GL')
assert PARAMS['test'].value == 'Gentoo Linux -> GL' assert PARAMS['test'].value == 'Gentoo Linux -> GL'
# Переменные, указанные в to_set не меняются если используется # Переменные, указанные в set_to не меняются если используется
# дефолтное значение, рассчитанное с помощью переменных. # дефолтное значение, рассчитанное с помощью переменных.
assert datavars.os.linux.subname == 'KDE' assert datavars.os.linux.subname == 'KDE'
@ -222,7 +222,7 @@ class TestParameters:
short='Test parameter', short='Test parameter',
full='Test parameters are needed for tests.'), full='Test parameters are needed for tests.'),
shortname='t' shortname='t'
).bind('os.linux.fullname').to_set( ).bind('os.linux.fullname').set_to(
'os.linux.shortname', 'os.linux.shortname',
'os.hashvar.value2')) 'os.hashvar.value2'))
@ -265,7 +265,7 @@ class TestParameters:
short='Test bool parameter', short='Test bool parameter',
full=('Test bool parameter, that can' full=('Test bool parameter, that can'
' disactivate other parameter')), ' disactivate other parameter')),
shortname='b').to_set('os.linux.test_2'), shortname='b').set_to('os.linux.test_2'),
StringTestParameter('test-string', 'Strings', StringTestParameter('test-string', 'Strings',
Description( Description(
short='Test string parameter', short='Test string parameter',
@ -586,7 +586,7 @@ class TestParameters:
Description(short='Test bool', Description(short='Test bool',
full=('Test parameters are' full=('Test parameters are'
' needed for tests.') ' needed for tests.')
)).to_set('os.linux.test_2')) )).set_to('os.linux.test_2'))
assert PARAMS['test-1'].disactivity_comment is None assert PARAMS['test-1'].disactivity_comment is None
PARAMS.set_parameters({'test-1': 'choice_2', 'test-2': True}) PARAMS.set_parameters({'test-1': 'choice_2', 'test-2': True})
@ -1001,7 +1001,7 @@ class TestParameters:
Description(short=('Test table'), Description(short=('Test table'),
full=('Test parameters are' full=('Test parameters are'
' needed for tests.')), ' needed for tests.')),
shortname='t').to_set('os.dev_table')) shortname='t').set_to('os.dev_table'))
assert PARAMS['test'].value.get_for_var() ==\ assert PARAMS['test'].value.get_for_var() ==\
[{'dev': '/dev/sdb1', 'mount': '/'}, [{'dev': '/dev/sdb1', 'mount': '/'},
{'dev': '/dev/sdb2', 'mount': '/var/calculate'}, {'dev': '/dev/sdb2', 'mount': '/var/calculate'},
@ -1075,7 +1075,7 @@ class TestParameters:
Description(short=('Test table'), Description(short=('Test table'),
full=('Test parameters are' full=('Test parameters are'
' needed for tests.')), ' needed for tests.')),
shortname='t').to_set('os.dev_table')) shortname='t').set_to('os.dev_table'))
assert PARAMS['test'].value.get_for_var() ==\ assert PARAMS['test'].value.get_for_var() ==\
[{'dev': '/dev/sdb1', 'mount': '/', [{'dev': '/dev/sdb1', 'mount': '/',
'name': 'Device 1', 'status': 'mounted'}, 'name': 'Device 1', 'status': 'mounted'},

@ -1,14 +1,38 @@
import os import os
import shutil import shutil
import pytest import pytest
from calculate.scripts.scripts import Var, Task, Static, TaskError, Done,\ from calculate.scripts.scripts import (
DoneAny, Success, SuccessAny, Failed,\ Var,
FailedAny, Block, For, While, Until,\ Task,
Handler, Script, Run, ScriptError,\ Static,
ActionError, ScriptLauncher, RunTemplate TaskError,
from calculate.variables.datavars import Namespace, Variable, IntegerType,\ Done,
StringType, BooleanType, ListType,\ DoneAny,
HashType Success,
SuccessAny,
Failed,
FailedAny,
Block,
For,
While,
Until,
Handler,
Script,
Run,
ScriptError,
ActionError,
ScriptLauncher,
RunTemplates
)
from calculate.variables.datavars import (
Namespace,
Variable,
IntegerType,
StringType,
BooleanType,
ListType,
HashType
)
from calculate.variables.loader import Datavars from calculate.variables.loader import Datavars
from calculate.utils.io_module import IOModule from calculate.utils.io_module import IOModule
@ -164,7 +188,7 @@ class TestTasks():
'success': True, 'success': True,
'error_message': None} 'error_message': None}
def test_if_script_object_is_created_with_one_correct_task_with_a_unfulfilled_condition__the_task_will_be_successfully_completed(self): def test_if_script_object_is_created_with_one_correct_task_with_a_unfulfilled_condition__the_task_will_not_be_completed(self):
Namespace.reset() Namespace.reset()
datavars = Namespace.datavars datavars = Namespace.datavars
@ -1874,11 +1898,11 @@ class TestTasks():
'variables')) 'variables'))
Script('test_script', Script('test_script',
).tasks(RunTemplate(id="templates_1", ).tasks(RunTemplates(id="templates_1",
action='action_1', action='action_1',
package="test-category/test-package", package="test-category/test-package",
chroot_path=TESTFILES_PATH, chroot_path=TESTFILES_PATH,
root_path="/etc"), root_path="/etc"),
).make_launcher(IOModule(), datavars, None)() ).make_launcher(IOModule(), datavars, None)()
assert 'test_script' in datavars.scripts assert 'test_script' in datavars.scripts
@ -1899,11 +1923,11 @@ class TestTasks():
assert 'test_3' in datavars.os.linux assert 'test_3' in datavars.os.linux
Script('test_script', Script('test_script',
).tasks(RunTemplate(id="templates_1", ).tasks(RunTemplates(id="templates_1",
action='action_2', action='action_2',
package="test-category/test-package", package="test-category/test-package",
chroot_path=TESTFILES_PATH, chroot_path=TESTFILES_PATH,
root_path="/etc"), root_path="/etc"),
).make_launcher(IOModule(), datavars, datavars.os)() ).make_launcher(IOModule(), datavars, datavars.os)()
assert 'test_script' in datavars.scripts assert 'test_script' in datavars.scripts

@ -0,0 +1,232 @@
import pytest
from calculate.server.utils.responses import ResponseStructure
@pytest.mark.responses
@pytest.mark.parametrize('case', [
{
"name": "One positional argument",
"args": ["Very important data"],
"kwargs": {},
"result": {"data": "Very important data"}
},
{
"name": "multiple positional args",
"args": [1, 2, 3],
"kwargs": {},
"result": {"data": [1, 2, 3]}
},
{
"name": "only keyword args",
"args": [],
"kwargs": {"name": "Calculate Server",
"version": "0.1"},
"result": {"data": {"name": "Calculate Server",
"version": "0.1"}}
},
{
"name": "Both positional and keyword args",
"args": [1, 2, 3],
"kwargs": {"name": "Calculate Server",
"version": "0.1"},
"result": {"data": [1, 2, 3]}
},
],
ids=lambda case: case["name"])
def test_creation_response_dictionary_using_only_add_data_method(case):
response = ResponseStructure("http://0.0.0.0:2007/")
response.add_data(*case["args"], **case["kwargs"])
assert response.get_dict() == case["result"]
@pytest.mark.responses
@pytest.mark.parametrize('case', [
{
"name": "Two calls with positional values",
"data": [
{"args": ["Very important data 1"],
"kwargs": {}},
{"args": ["Very important data 2"],
"kwargs": {}},
],
"result": {"data": ["Very important data 1",
"Very important data 2"]
},
},
{
"name": "Call with positional argument and with keywords one",
"data": [
{"args": ["Very important data"],
"kwargs": {}},
{"args": [],
"kwargs": {"name": "Calculate Server",
"version": "0.1"}},
],
"result": {"data": {"name": "Calculate Server",
"version": "0.1"}
},
},
{
"name": "Call with keyword arguments and with positional one",
"data": [
{"args": [],
"kwargs": {"name": "Calculate Server",
"version": "0.1"}},
{"args": ["Very important data"],
"kwargs": {}},
],
"result": {"data": "Very important data"},
},
{
"name": "Two calls with keyword arguments",
"data": [
{"args": [],
"kwargs": {"name": "Calculate Server"}},
{"args": [],
"kwargs": {"version": "0.1"}},
],
"result": {"data": {"name": "Calculate Server",
"version": "0.1"}
},
},
],
ids=lambda case: case["name"])
def test_multiple_add_data_method_callings(case):
response = ResponseStructure("http://0.0.0.0:2007/")
for data in case["data"]:
response.add_data(*data["args"], **data["kwargs"])
assert response.get_dict() == case["result"]
@pytest.mark.responses
@pytest.mark.parametrize('case', [
{
"name": "Passing not templated uri",
"action": "commands",
"uri": "/commands/",
"templated": False,
"result": {
"_links": {
"commands": {
"href": "http://0.0.0.0:2007/commands"
}
}
}
},
{
"name": "Passing templated uri",
"action": "find_command",
"uri": "commands/{cl_command}",
"templated": True,
"result": {
"_links": {
"find_command": {
"href": "http://0.0.0.0:2007/commands/{cl_command}",
"templated": True
}
}
}
},
],
ids=lambda case: case["name"])
def test_creation_response_dictionary_using_only_add_links_method(case):
response = ResponseStructure("http://0.0.0.0:2007/")
response.add_link(case["action"], case["uri"], case["templated"])
assert response.get_dict() == case["result"]
@pytest.mark.responses
@pytest.mark.parametrize('case', [
{
"name": "One embed method call",
"embed": {
"menu":
(ResponseStructure("http://0.0.0.0:2007/").
add_data(name="Calculate Server", version="0.1").
add_link("get_commands", "commands").
add_link("find_command", "commands/{cl_command}",
templated=True)
).get_dict(),
},
"result": {
"_embedded": {
"menu": {
"data": {
"name": "Calculate Server",
"version": "0.1"
},
"_links": {
"get_commands": {
"href": "http://0.0.0.0:2007/commands"
},
"find_command": {
"href": "http://0.0.0.0:2007/commands/{cl_command}",
"templated": True
},
}
}
}
}
},
{
"name": "Two embed method calls",
"embed": {
"first":
(ResponseStructure("http://0.0.0.0:2007/").
add_data(id=1, name="first", description="First value").
add_link("self", "values/1").
add_link("find_value", "values/search/{value_name}",
templated=True)
).get_dict(),
"second":
(ResponseStructure("http://0.0.0.0:2007/").
add_data(id=2, name="second", description="Second value").
add_link("self", "values/2").
add_link("find_value", "values/search/{value_name}",
templated=True)
).get_dict(),
},
"result": {
"_embedded": {
"first": {
"data": {
"id": 1,
"name": "first",
"description": "First value",
},
"_links": {
"self": {
"href": "http://0.0.0.0:2007/values/1"
},
"find_value": {
"href": "http://0.0.0.0:2007/values/search/{value_name}",
"templated": True
},
}
},
"second": {
"data": {
"id": 2,
"name": "second",
"description": "Second value",
},
"_links": {
"self": {
"href": "http://0.0.0.0:2007/values/2"
},
"find_value": {
"href": "http://0.0.0.0:2007/values/search/{value_name}",
"templated": True
},
}
}
}
}
},
],
ids=lambda case: case["name"])
def test_creation_response_dictionary_using_only_embed_method(case):
response = ResponseStructure("http://0.0.0.0:2007/")
for embed_name, embed_data in case["embed"].items():
response.embed(embed_name, embed_data)
assert response.get_dict() == case["result"]

@ -13,8 +13,10 @@ test_client = TestClient(app)
def authenticate(username: str, password: str): def authenticate(username: str, password: str):
request_headers = {"accept": "application/json", request_headers = {"accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded"} "Content-Type": "application/x-www-form-urlencoded"}
request_data = {"username": username, "password": password, request_data = {"username": username,
"grant_type": "password", "scope": None, "client_id": None, "password": password,
"grant_type": "password",
"scope": None,
"client_id": None} "client_id": None}
token = test_client.post("/auth", data=request_data, json=request_headers) token = test_client.post("/auth", data=request_data, json=request_headers)
token_header = token.json() token_header = token.json()
@ -33,29 +35,29 @@ def test_to_make_testfiles():
symlinks=True) symlinks=True)
@pytest.mark.server # @pytest.mark.server
def test_get_root_message(): # def test_get_root_message():
authorization_headers = authenticate("denis", "secret") # authorization_headers = authenticate("denis", "secret")
response = test_client.get("/", headers=authorization_headers) # response = test_client.get("/", headers=authorization_headers)
assert response.status_code == 200 # assert response.status_code == 200
assert response.json() == {"msg": "root msg"} # assert response.json() == {"msg": "root msg"}
@pytest.mark.server # @pytest.mark.server
def test_get_commands_list(): # def test_get_commands_list():
authorization_headers = authenticate("denis", "secret") # authorization_headers = authenticate("denis", "secret")
response = test_client.get("/commands", headers=authorization_headers) # response = test_client.get("/commands", headers=authorization_headers)
assert response.status_code == 200 # assert response.status_code == 200
assert response.json() == {"test_1": # assert response.json() == {"test_1":
{"title": "Test 1", # {"title": "Test 1",
"category": "Test Category", # "category": "Test Category",
"icon": "/path/to/icon_1.png", # "icon": "/path/to/icon_1.png",
"command": "test_1"}, # "command": "test_1"},
"test_2": # "test_2":
{"title": "Test 2", # {"title": "Test 2",
"category": "Test Category", # "category": "Test Category",
"icon": "/path/to/icon_2.png", # "icon": "/path/to/icon_2.png",
"command": "cl_test_2"}} # "command": "cl_test_2"}}
# @pytest.mark.server # @pytest.mark.server

@ -1666,7 +1666,7 @@ def test_if_append_join_file_method_s_input_is_a_template_with_protected_target_
@pytest.mark.template_executor @pytest.mark.template_executor
def test_if_append_join_file_method_s_input_is_a_template_with_protected_target_path_to_an_unexisting_file_that_should_exist_and_autoupdate_parameters_is_set__the_method_creates_new_empty_file_joins_a_template_with_them_and_adds_it_to_config_file(): def test_if_append_join_file_method_s_input_is_a_template_with_protected_target_path_to_an_unexisting_file_that_should_exist_and_autoupdate_parameter_is_set__the_method_creates_new_empty_file_joins_a_template_with_them_and_adds_it_to_config_file():
target_path = join_paths( target_path = join_paths(
CHROOT_PATH, CHROOT_PATH,
'/etc/append_join_file_testfiles/file_2') '/etc/append_join_file_testfiles/file_2')

@ -1,10 +1,12 @@
import pytest import pytest
import calculate.templates.template_filters as filters import calculate.templates.template_filters as filters
class TestObj: class TestObj:
def __str__(self): def __str__(self):
return "TestObj_value" return "TestObj_value"
@pytest.mark.parametrize('case', @pytest.mark.parametrize('case',
[ [
{ {

@ -56,6 +56,7 @@ dir /etc/append_link_dir_testfiles/link_dir_6
obj /etc/append_link_dir_testfiles/link_dir_6/file d41d8cd98f00b204e9800998ecf8427e 1592491327 obj /etc/append_link_dir_testfiles/link_dir_6/file d41d8cd98f00b204e9800998ecf8427e 1592491327
dir /etc/append_join_file_testfiles dir /etc/append_join_file_testfiles
obj /etc/append_join_file_testfiles/file_1 ee090b452dbf92d697124eb424f5de5b 1592552158 obj /etc/append_join_file_testfiles/file_1 ee090b452dbf92d697124eb424f5de5b 1592552158
obj /etc/append_join_file_testfiles/file_2 ee090b452dbf92d697124eb424f5de5b 1592552158
obj /etc/append_join_file_testfiles/file_4 ee090b452dbf92d697124eb424f5de5b 1592552158 obj /etc/append_join_file_testfiles/file_4 ee090b452dbf92d697124eb424f5de5b 1592552158
obj /etc/append_join_file_testfiles/file_5 ee090b452dbf92d697124eb424f5de5b 1592574626 obj /etc/append_join_file_testfiles/file_5 ee090b452dbf92d697124eb424f5de5b 1592574626
obj /etc/append_join_file_testfiles/file_6 ee090b452dbf92d697124eb424f5de5b 1592574626 obj /etc/append_join_file_testfiles/file_6 ee090b452dbf92d697124eb424f5de5b 1592574626

@ -3,6 +3,7 @@ from calculate.variables.loader import CalculateIniParser, Define
@pytest.mark.vars @pytest.mark.vars
@pytest.mark.calculateini
class TestCalculateIni: class TestCalculateIni:
def test_section_values(self): def test_section_values(self):
ini_parser = CalculateIniParser() ini_parser = CalculateIniParser()

Loading…
Cancel
Save