py.lib.aw_web_tools
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