py.lib.aw_web_tools

Yohn Y. 2024-02-25 Child:cb0dd8d1c0e9

0:06f00ec09030 0.202402.1 Browse Files

..init

.hgignore LICENSE pyproject.toml requirements.txt setup.py src/aw_web_tools/__init__.py src/aw_web_tools/btle_tools.py src/aw_web_tools/cookie.py src/aw_web_tools/jwt.py src/aw_web_tools/misc_tools.py src/aw_web_tools/url.py tool/make_pkg.sh

     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/.hgignore	Sun Feb 25 15:18:36 2024 +0300
     1.3 @@ -0,0 +1,6 @@
     1.4 +syntax: glob
     1.5 +.idea/*
     1.6 +.e/*
     1.7 +aw_web_tools.egg-info/*
     1.8 +build/*
     1.9 +dist/*
    1.10 \ No newline at end of file
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/LICENSE	Sun Feb 25 15:18:36 2024 +0300
     2.3 @@ -0,0 +1,29 @@
     2.4 +BSD 3-Clause License
     2.5 +
     2.6 +Copyright (c) 2024, awgur
     2.7 +All rights reserved.
     2.8 +
     2.9 +Redistribution and use in source and binary forms, with or without
    2.10 +modification, are permitted provided that the following conditions are met:
    2.11 +
    2.12 +1. Redistributions of source code must retain the above copyright notice, this
    2.13 +   list of conditions and the following disclaimer.
    2.14 +
    2.15 +2. Redistributions in binary form must reproduce the above copyright notice,
    2.16 +   this list of conditions and the following disclaimer in the documentation
    2.17 +   and/or other materials provided with the distribution.
    2.18 +
    2.19 +3. Neither the name of the copyright holder nor the names of its
    2.20 +   contributors may be used to endorse or promote products derived from
    2.21 +   this software without specific prior written permission.
    2.22 +
    2.23 +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    2.24 +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    2.25 +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
    2.26 +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
    2.27 +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
    2.28 +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
    2.29 +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
    2.30 +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
    2.31 +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
    2.32 +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     3.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     3.2 +++ b/pyproject.toml	Sun Feb 25 15:18:36 2024 +0300
     3.3 @@ -0,0 +1,18 @@
     3.4 +[build-system]
     3.5 +# Minimum requirements for the build system to execute.
     3.6 +requires = [
     3.7 +    "setuptools",
     3.8 +    "wheel"
     3.9 +]  # PEP 508 specifications.
    3.10 +
    3.11 +[project]
    3.12 +name = "aw_web_tools"
    3.13 +version = "0.202402.1"
    3.14 +requires-python = ">=3.8"
    3.15 +dependencies = [
    3.16 +    "PyJWT>=2.8.0",
    3.17 +    "bottle>=0.12.25",
    3.18 +]
    3.19 +
    3.20 +[project.urls]
    3.21 +src = "https://devel.a0fs.ru/py.lib.aw_web_tools/"
    3.22 \ No newline at end of file
     4.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     4.2 +++ b/requirements.txt	Sun Feb 25 15:18:36 2024 +0300
     4.3 @@ -0,0 +1,3 @@
     4.4 +PyJWT>=2.8.0
     4.5 +bottle>=0.12.25
     4.6 +setuptools>=68.2.0
     4.7 \ No newline at end of file
     5.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     5.2 +++ b/setup.py	Sun Feb 25 15:18:36 2024 +0300
     5.3 @@ -0,0 +1,11 @@
     5.4 +from setuptools import setup
     5.5 +
     5.6 +setup(
     5.7 +    name='aw_web_tools',
     5.8 +    version='0.202402.1',
     5.9 +    packages=['aw_web_tools'],
    5.10 +    package_dir={'aw_web_tools': 'src/aw_web_tools'},
    5.11 +    url='https://devel.a0fs.ru/py.lib.aw_web_tools/',
    5.12 +    author_email='',
    5.13 +    description=''
    5.14 +)
     6.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     6.2 +++ b/src/aw_web_tools/__init__.py	Sun Feb 25 15:18:36 2024 +0300
     6.3 @@ -0,0 +1,1 @@
     6.4 +# coding: utf-8
     7.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     7.2 +++ b/src/aw_web_tools/btle_tools.py	Sun Feb 25 15:18:36 2024 +0300
     7.3 @@ -0,0 +1,273 @@
     7.4 +# coding: utf-8
     7.5 +"""\
     7.6 +Набор инструментов, для более удобного взаимодействия с фреймворком ``bottle``
     7.7 +"""
     7.8 +import bottle
     7.9 +import json
    7.10 +from typing import Union, List, Optional, Any
    7.11 +from hashlib import sha512
    7.12 +from dataclasses import is_dataclass, asdict
    7.13 +
    7.14 +from .cookie import Cookie
    7.15 +
    7.16 +
    7.17 +VARIABLE_PREFIX = 'ru.a0fs.app'     # Префикс переменных и иных структур, которые сохраняются во внутренних
    7.18 +                                    # структурах ``bottle``
    7.19 +
    7.20 +# Имена заголовков взяты из собственного стандарта на конфигурарование nginx. При не совпадении нужно сменить.
    7.21 +IP_HEADER = 'X-Real-IP'             # Заголовок, в котором реверс-прокси хранит реальный IP клиента.
    7.22 +REQ_ID_HEADER = 'X-Request-Id'      # Заголовок запроса, в котором может храниться уникальный ID запроса,
    7.23 +                                    # выставленного реверс-прокси.
    7.24 +CONN_ID_HEADER = 'X-Conn-ID'        # Идентификатор текущего соединения.
    7.25 +
    7.26 +
    7.27 +def get_client_ip() -> str:
    7.28 +    """\
    7.29 +    Получение реального адреса клиента
    7.30 +    """
    7.31 +    ip = bottle.request.get_header(IP_HEADER)
    7.32 +    if ip:
    7.33 +        return ip
    7.34 +
    7.35 +    else:
    7.36 +        return bottle.request.remote_addr
    7.37 +
    7.38 +
    7.39 +def get_session_fingerprint(*add_params) -> str:
    7.40 +    """\
    7.41 +    Конструируем некий отпечаток пользовательской сессии.
    7.42 +
    7.43 +    В качестве параметра принимается всё, что должно участвовать в создании отпечатка.
    7.44 +    Это всё превращается в строки и подмешивается в хэш
    7.45 +    """
    7.46 +    ua = bottle.request.get_header('user-agent')
    7.47 +    add_params_mixin = ':'.join(map(str, add_params))
    7.48 +
    7.49 +    return sha512(f'{ua}:{get_client_ip()}:{add_params_mixin}'.encode('utf-8')).hexdigest().lower()
    7.50 +
    7.51 +
    7.52 +def variable_prep(value, new_value_type: type = str, postprocess: bool = True) -> Any:
    7.53 +    """\
    7.54 +    Более интеллектуальная процедура преобразования базовых типов
    7.55 +
    7.56 +    :param value: Значение, которое необходимо преобразовать
    7.57 +    :param new_value_type: Тип, в который необходимо преобразовать значение
    7.58 +    :param postprocess: Применять ли последующую обработку полученного результата
    7.59 +    :returns: Значение ``value`` преобразованное к типу ``value_type``
    7.60 +    """
    7.61 +    if value is None:
    7.62 +        return value
    7.63 +
    7.64 +    if issubclass(new_value_type, bool):
    7.65 +        if isinstance(value, str):
    7.66 +            if value.lower() in ('on', 'yes', '1', 'true',):
    7.67 +                return True
    7.68 +            elif value.lower() in ('off', 'no', '0', 'false',):
    7.69 +                return False
    7.70 +            else:
    7.71 +                raise ValueError(f'Unknown method translate "{value}" to "{new_value_type.__name__}"')
    7.72 +
    7.73 +    res = new_value_type(value)
    7.74 +
    7.75 +    if isinstance(res, str) and postprocess:
    7.76 +        res = res.strip()
    7.77 +
    7.78 +    return res
    7.79 +
    7.80 +
    7.81 +def get_env(name: str, default=None):
    7.82 +    """\
    7.83 +    Возвращает значения из хранилища контекста запроса bottle
    7.84 +    """
    7.85 +    _name = f'{VARIABLE_PREFIX}.{name}'
    7.86 +    if _name in bottle.request.environ:
    7.87 +        return bottle.request.environ[_name]
    7.88 +
    7.89 +    else:
    7.90 +        return default
    7.91 +
    7.92 +
    7.93 +def set_env(name: str, value):
    7.94 +    """\
    7.95 +    Устанавливает значения в хранилище контекста запроса bottle
    7.96 +    """
    7.97 +    bottle.request.environ[f'{VARIABLE_PREFIX}.{name}'] = value
    7.98 +
    7.99 +
   7.100 +def json_type_sanitizer(val):
   7.101 +    """\
   7.102 +    Преобразует значение ``val`` в пригодное для преобразования в json значение.
   7.103 +    """
   7.104 +    val_t = type(val)
   7.105 +
   7.106 +    if is_dataclass(val):
   7.107 +        return json_type_sanitizer(asdict(val))
   7.108 +
   7.109 +    elif val_t in (int, float, str, bool) or val is None:
   7.110 +        return val
   7.111 +
   7.112 +    elif val_t in (list, tuple):
   7.113 +        return list(map(json_type_sanitizer, val))
   7.114 +
   7.115 +    elif val_t == dict:
   7.116 +        return dict((key, json_type_sanitizer(d_val)) for key, d_val in val.items())
   7.117 +
   7.118 +    else:
   7.119 +        return str(val)
   7.120 +
   7.121 +
   7.122 +def make_response(
   7.123 +        data=None, status=200, cookies: Optional[List[Cookie]] = None,
   7.124 +        remove_cookies: Optional[List[Union[str, Cookie]]] = None
   7.125 +        ) -> bottle.HTTPResponse:
   7.126 +    """\
   7.127 +    Формирует ``API`` ответ.
   7.128 +    """
   7.129 +    if data is None:
   7.130 +        data = {}
   7.131 +
   7.132 +    res = bottle.HTTPResponse(body=json.dumps(json_type_sanitizer(data)), status=status)
   7.133 +    res.content_type = 'application/json'
   7.134 +
   7.135 +    if cookies is not None:
   7.136 +        for cookie in cookies:
   7.137 +            cookie.response_add(res)
   7.138 +
   7.139 +    if remove_cookies is not None and remove_cookies:
   7.140 +        for cookie in remove_cookies:
   7.141 +            if isinstance(cookie, Cookie):
   7.142 +                cookie.response_delete(res)
   7.143 +
   7.144 +            else:
   7.145 +                res.delete_cookie(cookie)
   7.146 +
   7.147 +    return res
   7.148 +
   7.149 +
   7.150 +def get_request_json() -> Optional[dict]:
   7.151 +    """\
   7.152 +    Если в запросе, который мы обрабатываем в текущий момент, использовалась посылка данных JSON, получить их.
   7.153 +    """
   7.154 +    res = bottle.request.json
   7.155 +    if res is None:
   7.156 +        raise ValueError('Не обнаружено JSON в запросе')
   7.157 +
   7.158 +    return res
   7.159 +
   7.160 +
   7.161 +def get_cookie(name: str) -> Optional[str]:
   7.162 +    """\
   7.163 +    Получить значение ``cookie`` с заданным именем из текущего запроса.
   7.164 +    """
   7.165 +    return bottle.request.get_cookie(name)
   7.166 +
   7.167 +
   7.168 +def get_param(name: str, param_type: Union[type, str] = str,
   7.169 +              default=None, postprocess: bool = True, param_source: str = None):
   7.170 +    """\
   7.171 +    Получить из обрабатываемого на данный момент запроса значения установленного параметра. Обрабатываются как
   7.172 +    ``GET``, так и ``POST`` параметры, если иное не указано конкретно.
   7.173 +
   7.174 +    :param name: Имя параметра
   7.175 +    :param param_type: Тип, в который нужно преобразовать полученный параметр
   7.176 +    :param default: Значение, отдаваемое, если параметр не установлен в запросе
   7.177 +    :param postprocess: Производить ли постобработку параметра
   7.178 +    :param param_source: Источник параметра, [``get``, ``post``]
   7.179 +    :returns Полученное из запроса значение
   7.180 +    """
   7.181 +    if param_source is None:
   7.182 +        res = bottle.request.params.getunicode(name)
   7.183 +    elif param_source.lower() == 'get':
   7.184 +        res = bottle.request.GET.getunicode(name)
   7.185 +    elif param_source.lower() == 'post':
   7.186 +        res = bottle.request.POST.getunicode(name)
   7.187 +    else:
   7.188 +        raise ValueError(f'Unknown parameter source "{param_source}" for "{name}"')
   7.189 +
   7.190 +    if res is None:
   7.191 +        res = default
   7.192 +
   7.193 +    if isinstance(param_type, str):
   7.194 +        if param_type.lower() == 'json':
   7.195 +            if not res:
   7.196 +                return None
   7.197 +
   7.198 +            else:
   7.199 +                try:
   7.200 +                    return json.loads(res)
   7.201 +
   7.202 +                except json.JSONDecodeError as e:
   7.203 +                    raise ValueError(f'Unable to get JSON from from parameter "{name}": param="{res}" error="{e}"')
   7.204 +    else:
   7.205 +        try:
   7.206 +            return variable_prep(res, new_value_type=param_type, postprocess=postprocess)
   7.207 +
   7.208 +        except (TypeError, ValueError) as e:
   7.209 +            raise ValueError(f'Error parsing parameter "{name}": {e}')
   7.210 +
   7.211 +
   7.212 +def make_log_topic(user: Optional[str] = None) -> str:
   7.213 +    """\
   7.214 +    Создаём строку, идентифицирующую данный запрос для журналирования операций.
   7.215 +
   7.216 +    :param user: Если известен пользователь, задаём его здесь
   7.217 +    """
   7.218 +    ip = get_client_ip()
   7.219 +    url_path = bottle.request.fullpath
   7.220 +    conn_id = bottle.request.get_header(CONN_ID_HEADER)
   7.221 +    req_id = bottle.request.get_header(REQ_ID_HEADER)
   7.222 +
   7.223 +    if req_id is not None:
   7.224 +        ip = f'{ip} | {req_id}'
   7.225 +
   7.226 +    elif conn_id is not None:
   7.227 +        ip = f'{ip} | {conn_id}'
   7.228 +
   7.229 +    if user is None:
   7.230 +        ip = f'_NOUID_[{ip}]'
   7.231 +
   7.232 +    else:
   7.233 +        ip = f'{user}[{ip}]'
   7.234 +
   7.235 +    return f'{ip} - {url_path}'
   7.236 +
   7.237 +
   7.238 +def make_error_response(
   7.239 +        code: int, err: Union[Exception, str],
   7.240 +        msg: str = None,
   7.241 +        remove_cookies: Optional[List[Union[str, Cookie]]] = None,
   7.242 +        cookies: Optional[List[Cookie]] = None
   7.243 +        ) -> bottle.HTTPResponse:
   7.244 +    """\
   7.245 +    Создание сообщения об ошибке для JSON REST API сервисов
   7.246 +
   7.247 +    :param code: HTTP-код ответа
   7.248 +    :param err: Либо объект исключения, либо название ошибки
   7.249 +    :param msg: Если не ``None`` - сообщение об ошибке. В противном случае, если ``err`` является исключением,
   7.250 +                сообщение берётся из ``str(err)`` если нет, становится пустой строкой.
   7.251 +    :param remove_cookies: Список cookie, которые должны быть удалены с клиента.
   7.252 +    :param cookies: Набор cookies вставляемый в конструктор ответа без изменений. Читать в соответствующем
   7.253 +                    параметре ``make_response``
   7.254 +    """
   7.255 +    if isinstance(err, Exception):
   7.256 +        err_type = type(err).__name__
   7.257 +        err_msg = str(err) if msg is None else msg
   7.258 +
   7.259 +    else:
   7.260 +        err_type = err
   7.261 +        err_msg = msg if msg is not None else ''
   7.262 +
   7.263 +    res = make_response({
   7.264 +        'error': err_type,
   7.265 +        'msg': err_msg,
   7.266 +        }, code, cookies)
   7.267 +
   7.268 +    if remove_cookies is not None and remove_cookies:
   7.269 +        for cookie in remove_cookies:
   7.270 +            if isinstance(cookie, Cookie):
   7.271 +                cookie.response_delete(res)
   7.272 +
   7.273 +            else:
   7.274 +                res.delete_cookie(cookie)
   7.275 +
   7.276 +    return res
     8.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     8.2 +++ b/src/aw_web_tools/cookie.py	Sun Feb 25 15:18:36 2024 +0300
     8.3 @@ -0,0 +1,81 @@
     8.4 +# coding: utf-8
     8.5 +
     8.6 +import bottle
     8.7 +from typing import Union, Any, Dict, Optional
     8.8 +from datetime import datetime
     8.9 +
    8.10 +
    8.11 +class Cookie(object):
    8.12 +    """\
    8.13 +    Класс хранящий ``cookie`` и способный их устанавливать в объекты http-ответов ``bottle``
    8.14 +    """
    8.15 +
    8.16 +    def __init__(self, name: str, value: str,
    8.17 +                 max_age: Optional[int] = None,
    8.18 +                 expires: Optional[Union[int, datetime]] = None,
    8.19 +                 path: Optional[str] = None,
    8.20 +                 secure: bool = True,
    8.21 +                 httponly: bool = True,
    8.22 +                 samesite: bool = False,
    8.23 +                 domain: Optional[str] = None):
    8.24 +        """\
    8.25 +        :param name: Имя ``cookie``
    8.26 +        :param value: Значение ``cookie``
    8.27 +        :param max_age: Время жизни ``cookie`` в секундах.
    8.28 +        :param expires: Значение времени, когда cookie будет удалена, задаётся в виде ``unix timestamp (int)`` или
    8.29 +                        ``datetime``
    8.30 +        :param path: Префикс пути поиска ресурса на данном сайте, для которого следует отправлять данное ``cookie``
    8.31 +        :param secure: Отправлять ``cookie`` только по шифрованным каналам связи
    8.32 +        :param httponly: Сделать ``cookie`` не доступной для ``JavaScript``
    8.33 +        :param samesite: Не отправлять данную cookie, если запрос пришёл не с того же сайта (анализируется заголовок
    8.34 +                         referer)
    8.35 +        :param domain: Имя домена в рамках которого выставляется cookie. В современных браузерах может и глючить
    8.36 +        """
    8.37 +
    8.38 +        self.name = name
    8.39 +        self.value = value
    8.40 +        self.max_age = max_age
    8.41 +        self.expires = expires
    8.42 +        self.path = path
    8.43 +        self.secure = secure
    8.44 +        self.httponly = httponly
    8.45 +        self.samesite = samesite
    8.46 +        self.domain = domain
    8.47 +
    8.48 +    def to_dict(self) -> Dict[str, Any]:
    8.49 +        """\
    8.50 +        Подготавливает параметры, которые можно передать в процедуру ``set_cookie``
    8.51 +        объекта ``Response``
    8.52 +        """
    8.53 +        res = {
    8.54 +            'name': self.name,
    8.55 +            'value': self.value,
    8.56 +            'secure': self.secure,
    8.57 +            'httponly': self.httponly,
    8.58 +        }
    8.59 +
    8.60 +        if self.samesite:
    8.61 +            res['samesite'] = 'strict'
    8.62 +
    8.63 +        for k in ('max_age', 'expires', 'path', 'domain'):
    8.64 +            if getattr(self, k) is not None:
    8.65 +                res[k] = getattr(self, k)
    8.66 +
    8.67 +        return res
    8.68 +
    8.69 +    def get_remove_param(self) -> Dict[str, Any]:
    8.70 +        """\
    8.71 +        Подготавливает параметры, которые можно передать в процедуру ``delete_cookie``
    8.72 +        объекта ``Response``
    8.73 +        """
    8.74 +        res = self.to_dict()
    8.75 +        res['key'] = res['name']
    8.76 +        del res['value']
    8.77 +        del res['name']
    8.78 +        return res
    8.79 +
    8.80 +    def response_add(self, resp: bottle.BaseResponse):
    8.81 +        resp.set_cookie(**self.to_dict())
    8.82 +
    8.83 +    def response_delete(self, resp: bottle.BaseResponse):
    8.84 +        resp.delete_cookie(**self.get_remove_param())
     9.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     9.2 +++ b/src/aw_web_tools/jwt.py	Sun Feb 25 15:18:36 2024 +0300
     9.3 @@ -0,0 +1,57 @@
     9.4 +# coding: utf-8
     9.5 +
     9.6 +import jwt
     9.7 +from datetime import datetime, timedelta
     9.8 +from typing import Optional
     9.9 +
    9.10 +JWT_HASH_ALGO = 'HS512'
    9.11 +
    9.12 +
    9.13 +class JWTError(Exception):
    9.14 +    pass
    9.15 +
    9.16 +
    9.17 +class JWTAuthError(JWTError):
    9.18 +    """\
    9.19 +    Провалена проверка токена на допустимость по подписи времени или прочее
    9.20 +    """
    9.21 +
    9.22 +
    9.23 +class JWTHelper(object):
    9.24 +    def __init__(self, key: str):
    9.25 +        self.key = key
    9.26 +
    9.27 +    def encode(self, data: dict, timeout: Optional[int] = None) -> str:
    9.28 +        if timeout is not None:
    9.29 +            data['exp'] = datetime.utcnow() + timedelta(seconds=timeout)
    9.30 +
    9.31 +        return jwt.encode(data, key=self.key, algorithm=JWT_HASH_ALGO)
    9.32 +
    9.33 +    def decode(self, token: str, check_timeout: bool = False) -> dict:
    9.34 +        opts = {
    9.35 +            'algorithms': [JWT_HASH_ALGO, ]
    9.36 +        }
    9.37 +
    9.38 +        if check_timeout:
    9.39 +            opts['options'] = {'require': {'exp'}}
    9.40 +
    9.41 +        if token is None:
    9.42 +            raise JWTAuthError('Ключ отсутствует')
    9.43 +        else:
    9.44 +            token = token.encode('utf-8')
    9.45 +
    9.46 +        try:
    9.47 +            return jwt.decode(jwt=token, key=self.key, **opts)
    9.48 +
    9.49 +        except (jwt.InvalidIssuerError, jwt.InvalidSignatureError, jwt.ExpiredSignatureError) as e:
    9.50 +            raise JWTAuthError(str(e))
    9.51 +
    9.52 +        except jwt.PyJWTError as e:
    9.53 +            raise JWTError(f'{type(e).__name__}: {e}')
    9.54 +
    9.55 +    @classmethod
    9.56 +    def make_fabric(cls, key: str):
    9.57 +        def f():
    9.58 +            return cls(key=key)
    9.59 +
    9.60 +        return f
    10.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    10.2 +++ b/src/aw_web_tools/misc_tools.py	Sun Feb 25 15:18:36 2024 +0300
    10.3 @@ -0,0 +1,9 @@
    10.4 +# coding: utf-8
    10.5 +from typing import Union, List, Tuple
    10.6 +
    10.7 +
    10.8 +def dict_filter(data: dict, need_keys: Union[List[str], Tuple[str]]) -> dict:
    10.9 +    """\
   10.10 +    Создать новый словарь на основе существующего, добавив в него только нужные ключи
   10.11 +    """
   10.12 +    return dict((key, val) for key, val in data.items() if key in need_keys)
   10.13 \ No newline at end of file
    11.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    11.2 +++ b/src/aw_web_tools/url.py	Sun Feb 25 15:18:36 2024 +0300
    11.3 @@ -0,0 +1,20 @@
    11.4 +# coding: utf-8 -*-
    11.5 +#  Инструменты для работы с URL
    11.6 +from base64 import urlsafe_b64encode as _b64e, urlsafe_b64decode as _b64d
    11.7 +from urllib.parse import quote, unquote
    11.8 +
    11.9 +
   11.10 +def b64_encode(buf: str) -> str:
   11.11 +    return _b64e(buf.encode('UTF-8')).decode('UTF-8')
   11.12 +
   11.13 +
   11.14 +def b64_decode(buf: str) -> str:
   11.15 +    return _b64d(buf.encode('UTF-8')).decode('UTF-8')
   11.16 +
   11.17 +
   11.18 +def url_quote(buf: str) -> str:
   11.19 +    return quote(buf)
   11.20 +
   11.21 +
   11.22 +def url_unquote(buf: str) -> str:
   11.23 +    return unquote(buf)
    12.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    12.2 +++ b/tool/make_pkg.sh	Sun Feb 25 15:18:36 2024 +0300
    12.3 @@ -0,0 +1,11 @@
    12.4 +#!/bin/sh
    12.5 +# devel.a0fs.ru -- devel:python.tools::make_pkg.sh -- v0.r202402.2
    12.6 +this_dir="$(dirname "$(readlink -f "$0")")"
    12.7 +this_pkg="$(dirname "$this_dir")"
    12.8 +
    12.9 +cd "${this_pkg}" || exit
   12.10 +if [ -d "${this_pkg}/.e" ] ; then
   12.11 +  source ${this_pkg}/.e/bin/activate
   12.12 +fi
   12.13 +
   12.14 +python3 setup.py bdist_wheel
   12.15 \ No newline at end of file