py.lib

Yohn Y. 2022-08-05 Parent:fe4a96d06243 Child:eb41cc498ddb

31:4186c3b229fa Browse Files

+ Модуль работы с датаклассами и их наполнения из ORM + Утилиты Bottle + Утилиты JWT + Помошник в парсинге конфигурационных файлов

config_parse_helper.py dataclass_utils.py webapp/bottle_urils.py webapp/jwt_util.py webapp/tool/__init__.py webapp/tool/url.py webapp/url.py

     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/config_parse_helper.py	Fri Aug 05 23:58:12 2022 +0300
     1.3 @@ -0,0 +1,199 @@
     1.4 +# coding: utf-8
     1.5 +from configparser import ConfigParser
     1.6 +from dataclasses import is_dataclass, fields, Field, MISSING
     1.7 +from typing import Iterable, Optional, Dict, Any
     1.8 +from threading import RLock
     1.9 +from os import getenv
    1.10 +
    1.11 +
    1.12 +class ConfigParseHelperError(Exception):
    1.13 +    """\
    1.14 +    Ошибки в модуле помощника разборщика конфигураций
    1.15 +    """
    1.16 +
    1.17 +
    1.18 +class NoSectionNotification(ConfigParseHelperError):
    1.19 +    """\
    1.20 +    Оповещение об отсутствующей секции конфигурации
    1.21 +    """
    1.22 +
    1.23 +
    1.24 +class CPHSectionBase:
    1.25 +    """\
    1.26 +    Базовый класс обработки секции конфигурационного файла
    1.27 +    """
    1.28 +
    1.29 +    def get(self, config_prop_name: str, dc_prop_name: str):
    1.30 +        """\
    1.31 +        Получить свойство из конфигурационного файла
    1.32 +        """
    1.33 +        raise NotImplemented()
    1.34 +
    1.35 +    def __enter__(self):
    1.36 +        return self
    1.37 +
    1.38 +    def __exit__(self, exc_type, exc_val, exc_tb):
    1.39 +        raise NotImplemented()
    1.40 +
    1.41 +
    1.42 +class CPHAbsentSection(CPHSectionBase):
    1.43 +    """\
    1.44 +    Класс создаваемый на отсутствующую секцию конфигурационного файла
    1.45 +    """
    1.46 +    def get(self, config_prop_name: str, dc_prop_name: str):
    1.47 +        raise NoSectionNotification()
    1.48 +
    1.49 +    def __exit__(self, exc_type, exc_val, exc_tb):
    1.50 +        if exc_type == NoSectionNotification:
    1.51 +            return True
    1.52 +
    1.53 +
    1.54 +class CPHParamGetter(CPHSectionBase):
    1.55 +    def __init__(self, parser_helper_object):
    1.56 +        self.ph = parser_helper_object
    1.57 +        self.params = {}
    1.58 +
    1.59 +    def _add_param(self, param_name: str, param_val: Any):
    1.60 +        """\
    1.61 +        Непосредственное добавление полученного параметра со всеми проверками.
    1.62 +        """
    1.63 +
    1.64 +        fld = self.ph.fields.get(param_name)
    1.65 +        if not isinstance(fld, Field):
    1.66 +            raise ConfigParseHelperError(f'В классе данных отсутствует свойство "{param_name}", '
    1.67 +                                         f'которое мы должны заполнить из параметра конфигурации: {fld}')
    1.68 +
    1.69 +        if param_val is not None:
    1.70 +            try:
    1.71 +                res = fld.type(param_val)
    1.72 +
    1.73 +            except (ValueError, TypeError) as e:
    1.74 +                raise ConfigParseHelperError(f'При приведении параметра к '
    1.75 +                                             f'заданному типу произошла ошибка: '
    1.76 +                                             f'значение="{param_val}" ошибка="{e}"')
    1.77 +
    1.78 +        else:
    1.79 +            if fld.default is not MISSING:
    1.80 +                res = fld.default
    1.81 +
    1.82 +            elif fld.default_factory is not MISSING:
    1.83 +                res = fld.default_factory()
    1.84 +
    1.85 +            else:
    1.86 +                raise ConfigParseHelperError('В конфигурации не заданна обязательная опция')
    1.87 +
    1.88 +        self.params[param_name] = res
    1.89 +
    1.90 +    def __exit__(self, exc_type, exc_val, exc_tb):
    1.91 +        if exc_type is None:
    1.92 +            self.ph.add_params(self.params)
    1.93 +
    1.94 +    def get(self, config_prop_name: str, dc_prop_name: str):
    1.95 +        raise NoSectionNotification()
    1.96 +
    1.97 +
    1.98 +class CPHSection(CPHParamGetter):
    1.99 +    """\
   1.100 +    Класс производящий разбор конкретной секции конфигурации
   1.101 +    """
   1.102 +
   1.103 +    def __init__(self, parser_helper_object, section: str):
   1.104 +        super().__init__(parser_helper_object)
   1.105 +        self.section_name = section
   1.106 +        self.section = parser_helper_object.conf_parser[section]
   1.107 +
   1.108 +    def get(self, config_prop_name: str, dc_prop_name: str):
   1.109 +        """\
   1.110 +        :param config_prop_name: Имя опции в файле конфигурации
   1.111 +        :param dc_prop_name: Имя свойства в классе данных, хранящем конфигурацию
   1.112 +        """
   1.113 +        try:
   1.114 +            self._add_param(dc_prop_name, self.section.get(config_prop_name))
   1.115 +
   1.116 +        except ConfigParseHelperError as e:
   1.117 +            raise ConfigParseHelperError(f'Ошибка при разборе параметра "{config_prop_name}" '
   1.118 +                                         f'в секции "{self.section_name}": {e}')
   1.119 +
   1.120 +
   1.121 +class CPHEnvParser(CPHParamGetter):
   1.122 +    """\
   1.123 +    Класс для разбора переменных окружения в том же ключе, что и файла конфигурации
   1.124 +    """
   1.125 +
   1.126 +    def get(self, env_name: str, dc_prop_name: str):
   1.127 +        """\
   1.128 +        :param env_name: Имя переменной окружения, хранящей опцию
   1.129 +        :param dc_prop_name: Имя свойства в классе данных, хранящем конфигурацию
   1.130 +        """
   1.131 +
   1.132 +        try:
   1.133 +            self._add_param(dc_prop_name, getenv(env_name))
   1.134 +
   1.135 +        except ConfigParseHelperError as e:
   1.136 +            raise ConfigParseHelperError(f'Ошибка при получении значения из переменной окружения "{env_name}": {e}')
   1.137 +
   1.138 +
   1.139 +class ConfigParseHelper:
   1.140 +    def __init__(self, config_object_class: type, required_sections: Optional[Iterable[str]] = None):
   1.141 +        """\
   1.142 +        :param config_object_class: Dataclass, который мы подготовили как хранилище параметров конфигурации
   1.143 +        :param required_sections: Перечисление секций конфигурации, которые мы требуем, чтобы были в файле
   1.144 +        """
   1.145 +
   1.146 +        if required_sections is not None:
   1.147 +            self.req_sections = set(required_sections)
   1.148 +
   1.149 +        else:
   1.150 +            self.req_sections = set()
   1.151 +
   1.152 +        if not is_dataclass(config_object_class):
   1.153 +            raise ConfigParseHelperError(f'Представленный в качестве объекта конфигурации класс не является '
   1.154 +                                         f'классом данных: {config_object_class.__name__}')
   1.155 +
   1.156 +        self.res_obj = config_object_class
   1.157 +        self.fields = dict((fld.name, fld) for fld in fields(config_object_class))
   1.158 +        self.conf_parser: Optional[ConfigParser] = None
   1.159 +        self.config_params = {}
   1.160 +        self.config_params_lock = RLock()
   1.161 +
   1.162 +    def add_params(self, params: Dict[str, Any]):
   1.163 +        self.config_params_lock.acquire()
   1.164 +        try:
   1.165 +            self.config_params.update(params)
   1.166 +
   1.167 +        finally:
   1.168 +            self.config_params_lock.release()
   1.169 +
   1.170 +    def section(self, section_name: str) -> CPHSectionBase:
   1.171 +        if self.conf_parser is None:
   1.172 +            raise ConfigParseHelperError(f'Прежде чем приступать к разбору файла конфигурации стоит его загрузить')
   1.173 +
   1.174 +        if self.conf_parser.has_section(section_name):
   1.175 +            return CPHSection(self, section_name)
   1.176 +
   1.177 +        else:
   1.178 +            return CPHAbsentSection()
   1.179 +
   1.180 +    def load(self, filename: str):
   1.181 +        res = ConfigParser()
   1.182 +        try:
   1.183 +            res.read(filename)
   1.184 +
   1.185 +        except (TypeError, IOError, OSError, ValueError) as e:
   1.186 +            raise ConfigParseHelperError(f'Не удалось загрузить файл конфигурации: файл="{filename}" '
   1.187 +                                         f'ошибка="{e}"')
   1.188 +
   1.189 +        missing_sections = self.req_sections - set(res.sections())
   1.190 +
   1.191 +        if missing_sections:
   1.192 +            missing_sections = ', '.join(missing_sections)
   1.193 +            raise ConfigParseHelperError(f'В конфигурационном файле отсутствуют секции: {missing_sections}')
   1.194 +
   1.195 +        self.conf_parser = res
   1.196 +
   1.197 +    def get_config(self):
   1.198 +        try:
   1.199 +            return self.res_obj(**self.config_params)
   1.200 +
   1.201 +        except (ValueError, TypeError) as e:
   1.202 +            raise ConfigParseHelperError(f'Не удалось инициализировать объект конфигурации: {e}')
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/dataclass_utils.py	Fri Aug 05 23:58:12 2022 +0300
     2.3 @@ -0,0 +1,66 @@
     2.4 +# coding: utf-8
     2.5 +
     2.6 +from dataclasses import fields, dataclass, is_dataclass
     2.7 +from typing import Union, Dict, Any
     2.8 +
     2.9 +
    2.10 +def _dict_has(obj: Dict[str, Any], key: str) -> bool:
    2.11 +    return key in obj
    2.12 +
    2.13 +
    2.14 +def _dict_get(obj: Dict[str, Any], key: str) -> Any:
    2.15 +    return obj[key]
    2.16 +
    2.17 +
    2.18 +def _obj_has(obj: object, key: str) -> bool:
    2.19 +    return hasattr(obj, key)
    2.20 +
    2.21 +
    2.22 +def _obj_get(obj: object, key: str) -> Any:
    2.23 +    return getattr(obj, key)
    2.24 +
    2.25 +
    2.26 +def dataobj_extract(obj: Union[object, Dict[str, Any]], dataclass_type: type) -> dataclass:
    2.27 +    """\
    2.28 +    Извлекает объект данных из предоставленного объекта, путём получения из него
    2.29 +    указанных в классе данных аттрибутов и поиска их в данном объекте.
    2.30 +    """
    2.31 +
    2.32 +    params = {}
    2.33 +
    2.34 +    if isinstance(obj, dict):
    2.35 +        _has = _dict_has
    2.36 +        _get = _dict_get
    2.37 +
    2.38 +    else:
    2.39 +        _has = _obj_has
    2.40 +        _get = _obj_get
    2.41 +
    2.42 +    if not is_dataclass(dataclass_type):
    2.43 +        raise ValueError(f'Не относится к классам данных: {dataclass_type.__name__}')
    2.44 +
    2.45 +    for fld in fields(dataclass_type):
    2.46 +        if _has(obj, fld.name):
    2.47 +            val = _get(obj, fld.name)
    2.48 +            if val is not None and not isinstance(val, fld.type):
    2.49 +                try:
    2.50 +                    val = fld.type(val)
    2.51 +
    2.52 +                except (ValueError, TypeError) as e:
    2.53 +                    raise ValueError(f'Аттрибут {fld.name} не может быть получен из значения "{val}"'
    2.54 +                                     f' с типом {type(val).__name__} поскольку не может быть преобразован в'
    2.55 +                                     f' тип {fld.type.__name__}, заданный в классе данных: {e}')
    2.56 +
    2.57 +            params[fld.name] = val
    2.58 +
    2.59 +    try:
    2.60 +        res = dataclass_type(**params)
    2.61 +
    2.62 +    except (ValueError, TypeError) as e:
    2.63 +        _params = ', '.join(map(lambda x: f'{x[0]}="{x[1]}"', params.items()))
    2.64 +        raise ValueError(f'Не удалось получить объект'
    2.65 +                         f' класс {dataclass_type.__name__}'
    2.66 +                         f' из параметров: {_params}'
    2.67 +                         f' ошибка: {e}')
    2.68 +
    2.69 +    return res
     3.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     3.2 +++ b/webapp/bottle_urils.py	Fri Aug 05 23:58:12 2022 +0300
     3.3 @@ -0,0 +1,325 @@
     3.4 +# coding: utf-8
     3.5 +"""\
     3.6 +Набор инструментов, для более удобного взаимодействия с фреймворком ``bottle``
     3.7 +"""
     3.8 +
     3.9 +import bottle
    3.10 +import json
    3.11 +from typing import Union, List, Optional, Tuple, Any
    3.12 +from datetime import datetime
    3.13 +from hashlib import sha512
    3.14 +from dataclasses import is_dataclass, asdict
    3.15 +
    3.16 +
    3.17 +VARIABLE_PREFIX = 'ru.a0fs.app'     # Префикс переменных и иных структур, которые сохраняются во внутренних
    3.18 +                                    # структурах ``bottle``
    3.19 +IP_HEADER = 'X-Real-IP'             # Заголовок, в котором реверс-прокси хранит реальный IP клиента.
    3.20 +ID_HEADER = 'X-Req-ID'              # Заголовок запроса, в котором может храниться уникальный ID запроса,
    3.21 +                                    # выставленного реверс-прокси.
    3.22 +CONN_ID_HEADER = 'X-Conn-ID'        # Идентификатор текущего соединения.
    3.23 +
    3.24 +
    3.25 +class Cookie(object):
    3.26 +    """\
    3.27 +    Класс хранящий ``cookie`` и способный их устанавливать в объекты http-ответов ``bottle``
    3.28 +    """
    3.29 +
    3.30 +    def __init__(self, name: str, value: str,
    3.31 +                 max_age: int = None, expires: Union[int, datetime] = None, path: str = None,
    3.32 +                 secure: bool = True, httponly: bool = True,
    3.33 +                 samesite: bool = False, domain: str = None):
    3.34 +        """\
    3.35 +        :param name: Имя ``cookie``
    3.36 +        :param value: Значение ``cookie``
    3.37 +        :param max_age: Время жизни ``cookie`` в секундах.
    3.38 +        :param expires: Значение времени, когда cookie будет удалена, задаётся в виде ``unix timestamp (int)`` или
    3.39 +                        ``datetime``
    3.40 +        :param path: Префикс пути поиска ресурса на данном сайте, для которого следует отправлять данное ``cookie``
    3.41 +        :param secure: Отправлять ``cookie`` только по шифрованным каналам связи
    3.42 +        :param httponly: Сделать ``cookie`` не доступной для ``JavaScript``
    3.43 +        :param samesite: Не отправлять данную cookie, если запрос пришёл не с того же сайта (анализируется заголовок
    3.44 +                         referer)
    3.45 +        :param domain: Имя домена в рамках которого выставляется cookie. В современных браузерах может и глючить
    3.46 +        """
    3.47 +
    3.48 +        self.name = name
    3.49 +        self.value = value
    3.50 +        self.max_age = max_age
    3.51 +        self.expires = expires
    3.52 +        self.path = path
    3.53 +        self.secure = secure
    3.54 +        self.httponly = httponly
    3.55 +        self.samesite = samesite
    3.56 +        self.domain = domain
    3.57 +
    3.58 +    def response_add(self, resp: bottle.BaseResponse):
    3.59 +        res = {
    3.60 +            'name': self.name,
    3.61 +            'value': self.value,
    3.62 +            'secure': self.secure,
    3.63 +            'httponly': self.httponly,
    3.64 +        }
    3.65 +
    3.66 +        if self.samesite:
    3.67 +            res['samesite'] = 'strict'
    3.68 +
    3.69 +        for k in ('max_age', 'expires', 'path', 'domain'):
    3.70 +            if getattr(self, k) is not None:
    3.71 +                res[k] = getattr(self, k)
    3.72 +
    3.73 +        resp.set_cookie(**res)
    3.74 +
    3.75 +
    3.76 +def get_client_ip() -> str:
    3.77 +    """\
    3.78 +    Получение реального адреса клиента
    3.79 +    """
    3.80 +
    3.81 +    ip = bottle.request.get_header(IP_HEADER)
    3.82 +    if ip:
    3.83 +        return ip
    3.84 +
    3.85 +    else:
    3.86 +        return bottle.request.remote_addr
    3.87 +
    3.88 +
    3.89 +def get_session_fingerprint(*add_params) -> str:
    3.90 +    """\
    3.91 +    Конструируем некий отпечаток пользовательской сессии.
    3.92 +
    3.93 +    В качестве параметра принимается всё, что должно участвовать в создании отпечатка.
    3.94 +    Это всё превращается в строки и подмешивается в хэш
    3.95 +    """
    3.96 +
    3.97 +    ua = bottle.request.get_header('user-agent')
    3.98 +    add_params_mixin = ':'.join(map(str, add_params))
    3.99 +
   3.100 +    return sha512(f'{ua}:{get_client_ip()}:{add_params_mixin}'.encode('utf-8')).hexdigest().lower()
   3.101 +
   3.102 +
   3.103 +def variable_prep(value, new_value_type: type = str, postprocess: bool = True) -> Any:
   3.104 +    """\
   3.105 +    Более интеллектуальная процедура преобразования базовых типов
   3.106 +
   3.107 +    :param value: Значение, которое необходимо преобразовать
   3.108 +    :param new_value_type: Тип в который необходимо преобразовать значение
   3.109 +    :param postprocess: Применять ли последующую обработку полученного результата
   3.110 +    :returns: Значение ``value`` преобразованное к типу ``value_type``
   3.111 +    """
   3.112 +
   3.113 +    if value is None:
   3.114 +        return value
   3.115 +
   3.116 +    if issubclass(new_value_type, bool):
   3.117 +        if isinstance(value, str):
   3.118 +            if value.lower() in ('on', 'yes', '1', 'true',):
   3.119 +                return True
   3.120 +            elif value.lower() in ('off', 'no', '0', 'false',):
   3.121 +                return False
   3.122 +            else:
   3.123 +                raise ValueError(f'Unknown method translate "{value}" to "{new_value_type.__name__}"')
   3.124 +
   3.125 +    res = new_value_type(value)
   3.126 +
   3.127 +    if isinstance(res, str) and postprocess:
   3.128 +        res = res.strip()
   3.129 +
   3.130 +    return res
   3.131 +
   3.132 +
   3.133 +def get_variable(name: str, default=None):
   3.134 +    """\
   3.135 +    Возвращает значения из хранилища контекста запроса bottle
   3.136 +    """
   3.137 +
   3.138 +    _name = f'{VARIABLE_PREFIX}.{name}'
   3.139 +    if _name in bottle.request.environ:
   3.140 +        return bottle.request.environ[_name]
   3.141 +
   3.142 +    else:
   3.143 +        return default
   3.144 +
   3.145 +
   3.146 +def set_variable(name: str, value):
   3.147 +    """\
   3.148 +    Устанавливает значения в хранилище контекста запроса bottle
   3.149 +    """
   3.150 +
   3.151 +    bottle.request.environ[f'{VARIABLE_PREFIX}.{name}'] = value
   3.152 +
   3.153 +
   3.154 +def make_response(
   3.155 +        data=None, status=200, cookies: Optional[List[Cookie]] = None
   3.156 +) -> bottle.HTTPResponse:
   3.157 +    """\
   3.158 +    Формирует ``API`` ответ.
   3.159 +    """
   3.160 +
   3.161 +    if data is None:
   3.162 +        data = {}
   3.163 +
   3.164 +    res = bottle.HTTPResponse(body=json.dumps(json_type_sanitizer(data)), status=status)
   3.165 +    res.content_type = 'application/json'
   3.166 +
   3.167 +    if cookies is not None:
   3.168 +        for cookie in cookies:
   3.169 +            cookie.response_add(res)
   3.170 +
   3.171 +    return res
   3.172 +
   3.173 +
   3.174 +def get_request_json() -> Optional[dict]:
   3.175 +    """\
   3.176 +    Если в запросе, который мы обрабатываем в текущий момент, использовалась посылка данных JSON, получить их.
   3.177 +    """
   3.178 +
   3.179 +    res = bottle.request.json
   3.180 +    if res is None:
   3.181 +        raise ValueError('Не обнаружено JSON в запросе')
   3.182 +
   3.183 +    return res
   3.184 +
   3.185 +
   3.186 +def get_cookie(name: str) -> Optional[str]:
   3.187 +    """\
   3.188 +    Получить значение ``cookie`` с заданным именем из текущего запроса.
   3.189 +    """
   3.190 +
   3.191 +    return bottle.request.get_cookie(name)
   3.192 +
   3.193 +
   3.194 +def get_param(name: str, param_type: Union[type, str] = str,
   3.195 +              default=None, postprocess: bool = True, param_source: str = None):
   3.196 +    """\
   3.197 +    Получить из обрабатываемого на данный момент запроса значения установленного параметра. Обрабатываются как
   3.198 +    ``GET``, так и ``POST`` параметры, если иное не указано конкретно.
   3.199 +
   3.200 +    :param name: Имя параметра
   3.201 +    :param param_type: Тип, в который нужно преобразовать полученный параметр
   3.202 +    :param default: Значение, отдаваемое, если параметр не установлен в запросе
   3.203 +    :param postprocess: Производить ли постобработку параметра
   3.204 +    :param param_source: Источник параметра, [``get``, ``post``]
   3.205 +    :returns Полученное из запроса значение
   3.206 +    """
   3.207 +
   3.208 +    if param_source is None:
   3.209 +        res = bottle.request.params.getunicode(name)
   3.210 +    elif param_source.lower() == 'get':
   3.211 +        res = bottle.request.GET.getunicode(name)
   3.212 +    elif param_source.lower() == 'post':
   3.213 +        res = bottle.request.POST.getunicode(name)
   3.214 +    else:
   3.215 +        raise ValueError(f'Unknown parameter source "{param_source}" for "{name}"')
   3.216 +
   3.217 +    if res is None:
   3.218 +        res = default
   3.219 +
   3.220 +    if isinstance(param_type, str):
   3.221 +        if param_type.lower() == 'json':
   3.222 +            if not res:
   3.223 +                return None
   3.224 +
   3.225 +            else:
   3.226 +                try:
   3.227 +                    return json.loads(res)
   3.228 +
   3.229 +                except json.JSONDecodeError as e:
   3.230 +                    raise ValueError(f'Unable to get JSON from from parameter "{name}": param="{res}" error="{e}"')
   3.231 +    else:
   3.232 +        try:
   3.233 +            return variable_prep(res, new_value_type=param_type, postprocess=postprocess)
   3.234 +
   3.235 +        except (TypeError, ValueError) as e:
   3.236 +            raise ValueError(f'Error parsing parameter "{name}": {e}')
   3.237 +
   3.238 +
   3.239 +def dict_filter(data: dict, need_keys: Union[List[str], Tuple[str]]) -> dict:
   3.240 +    """\
   3.241 +    Создать новый словарь на основе существующего, добавив в него только нужные ключи
   3.242 +    """
   3.243 +
   3.244 +    return dict((key, val) for key, val in data.items() if key in need_keys)
   3.245 +
   3.246 +
   3.247 +def json_type_sanitizer(val):
   3.248 +    """\
   3.249 +    Преобразует значение ``val`` в пригодное для преобразования в json значение.
   3.250 +    """
   3.251 +
   3.252 +    val_t = type(val)
   3.253 +
   3.254 +    if is_dataclass(val):
   3.255 +        return json_type_sanitizer(asdict(val))
   3.256 +
   3.257 +    elif val_t in (int, float, str, bool) or val is None:
   3.258 +        return val
   3.259 +
   3.260 +    elif val_t in (list, tuple):
   3.261 +        return list(map(json_type_sanitizer, val))
   3.262 +
   3.263 +    elif val_t == dict:
   3.264 +        return dict((key, json_type_sanitizer(d_val)) for key, d_val in val.items())
   3.265 +
   3.266 +    else:
   3.267 +        return str(val)
   3.268 +
   3.269 +
   3.270 +def make_log_ident(user: Optional[str] = None) -> str:
   3.271 +    """\
   3.272 +    Создаём строку, идентифицирующую данный запрос для журналирования операций.
   3.273 +
   3.274 +    :param user: Если известен пользователь, задаём его здесь
   3.275 +    """
   3.276 +
   3.277 +    ip = get_client_ip()
   3.278 +    url_path = bottle.request.fullpath
   3.279 +    conn_id = bottle.request.get_header(CONN_ID_HEADER)
   3.280 +
   3.281 +    if conn_id is not None:
   3.282 +        ip = f'{ip} | {conn_id}'
   3.283 +
   3.284 +    if user is not None:
   3.285 +        ip = f'_NOID_[{ip}]'
   3.286 +
   3.287 +    else:
   3.288 +        ip = f'{user}[{ip}]'
   3.289 +
   3.290 +    return f'{ip} - {url_path}'
   3.291 +
   3.292 +
   3.293 +def make_error_response(
   3.294 +        code: int, err: Union[Exception, str],
   3.295 +        msg: str = None,
   3.296 +        remove_cookies: Optional[List[str]] = None,
   3.297 +        cookies: Optional[List[Cookie]] = None
   3.298 +) -> bottle.HTTPResponse:
   3.299 +    """\
   3.300 +    Создание сообщения об ошибке для JSON REST API сервисов
   3.301 +
   3.302 +    :param code: HTTP-код ответа
   3.303 +    :param err: Либо объект исключения, либо название ошибки
   3.304 +    :param msg: Если не ``None`` - сообщение об ошибке. В противном случае, если ``err`` является исключением,
   3.305 +                сообщение берётся из ``str(err)`` если нет, становится пустой строкой.
   3.306 +    :param remove_cookies: Список cookie, которые должны быть удалены с клиента.
   3.307 +    :param cookies: Набор cookies вставляемый в конструктор ответа без изменений. Читать в соответствующем
   3.308 +                    параметре ``make_response``
   3.309 +    """
   3.310 +
   3.311 +    if isinstance(err, Exception):
   3.312 +        err_type = type(err).__name__
   3.313 +        err_msg = str(err) if msg is None else msg
   3.314 +
   3.315 +    else:
   3.316 +        err_type = err
   3.317 +        err_msg = msg if msg is not None else ''
   3.318 +
   3.319 +    res = make_response({
   3.320 +        'error': err_type,
   3.321 +        'msg': err_msg,
   3.322 +    }, code, cookies)
   3.323 +
   3.324 +    if remove_cookies is not None:
   3.325 +        for cookie_name in remove_cookies:
   3.326 +            res.delete_cookie(cookie_name)
   3.327 +
   3.328 +    return res
     4.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     4.2 +++ b/webapp/jwt_util.py	Fri Aug 05 23:58:12 2022 +0300
     4.3 @@ -0,0 +1,58 @@
     4.4 +# coding: utf-8
     4.5 +
     4.6 +import jwt
     4.7 +from datetime import datetime, timedelta
     4.8 +from typing import Optional
     4.9 +
    4.10 +
    4.11 +JWT_HASH_ALGO = 'HS512'
    4.12 +
    4.13 +
    4.14 +class JWTError(Exception):
    4.15 +    pass
    4.16 +
    4.17 +
    4.18 +class JWTAuthError(JWTError):
    4.19 +    """\
    4.20 +    Провалена проверка токена на допустимость по подписи времени или прочее
    4.21 +    """
    4.22 +
    4.23 +
    4.24 +class JWTHelper(object):
    4.25 +    def __init__(self, key: str):
    4.26 +        self.key = key
    4.27 +
    4.28 +    def encode(self, data: dict, timeout: Optional[int] = None) -> str:
    4.29 +        if timeout is not None:
    4.30 +            data['exp'] = datetime.utcnow() + timedelta(seconds=timeout)
    4.31 +
    4.32 +        return jwt.encode(data, key=self.key, algorithm=JWT_HASH_ALGO)
    4.33 +
    4.34 +    def decode(self, token: str, check_timeout: bool = False) -> dict:
    4.35 +        opts = {
    4.36 +            'algorithms': [JWT_HASH_ALGO, ]
    4.37 +        }
    4.38 +
    4.39 +        if check_timeout:
    4.40 +            opts['options'] = {'require': {'exp'}}
    4.41 +
    4.42 +        if token is None:
    4.43 +            raise JWTAuthError('Ключ отсутствует')
    4.44 +        else:
    4.45 +            token = token.encode('utf-8')
    4.46 +
    4.47 +        try:
    4.48 +            return jwt.decode(jwt=token, key=self.key, **opts)
    4.49 +
    4.50 +        except (jwt.InvalidIssuerError, jwt.InvalidSignatureError, jwt.ExpiredSignatureError) as e:
    4.51 +            raise JWTAuthError(str(e))
    4.52 +
    4.53 +        except jwt.PyJWTError as e:
    4.54 +            raise JWTError(f'{type(e).__name__}: {e}')
    4.55 +
    4.56 +    @classmethod
    4.57 +    def make_cls_fabric(cls, key: str):
    4.58 +        def f():
    4.59 +            return cls(key=key)
    4.60 +
    4.61 +        return f
    4.62 \ No newline at end of file
     5.1 --- a/webapp/tool/__init__.py	Sun Jul 17 22:26:31 2022 +0300
     5.2 +++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
     5.3 @@ -1,4 +0,0 @@
     5.4 -# -*- coding: utf-8 -*-
     5.5 -# ---
     5.6 -#  
     5.7 -# ---
     5.8 \ No newline at end of file
     6.1 --- a/webapp/tool/url.py	Sun Jul 17 22:26:31 2022 +0300
     6.2 +++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
     6.3 @@ -1,12 +0,0 @@
     6.4 -# -*- coding: utf-8 -*-
     6.5 -# ---
     6.6 -#  Инструменты для работы с URL
     6.7 -# ---
     6.8 -from base64 import urlsafe_b64encode as _b64e, urlsafe_b64decode as _b64d
     6.9 -from urllib.parse import quote as urlquote
    6.10 -
    6.11 -def encode(buf):
    6.12 -  return _b64e(buf.encode('UTF-8')).decode('UTF-8')
    6.13 -  
    6.14 -def decode(buf):
    6.15 -  return _b64d(buf.encode('UTF-8')).decode('UTF-8')
     7.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     7.2 +++ b/webapp/url.py	Fri Aug 05 23:58:12 2022 +0300
     7.3 @@ -0,0 +1,12 @@
     7.4 +# -*- coding: utf-8 -*-
     7.5 +# ---
     7.6 +#  Инструменты для работы с URL
     7.7 +# ---
     7.8 +from base64 import urlsafe_b64encode as _b64e, urlsafe_b64decode as _b64d
     7.9 +from urllib.parse import quote as urlquote
    7.10 +
    7.11 +def encode(buf):
    7.12 +  return _b64e(buf.encode('UTF-8')).decode('UTF-8')
    7.13 +  
    7.14 +def decode(buf):
    7.15 +  return _b64d(buf.encode('UTF-8')).decode('UTF-8')