Release 1.4.0

This commit is contained in:
2022-08-13 10:20:06 +02:00
parent 9bb72f0207
commit 5452306c72
162 changed files with 10015 additions and 4419 deletions

View File

@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '1.3.0'
__version__ = '1.4.0'

View File

@@ -11,8 +11,8 @@ from aiojobs.aiohttp import setup as setup_aiojobs
from homehub_backend import handlers
from homehub_backend.defs import FRONTEND_DIR
from homehub_backend.lib.application import HomeHubApplication
from homehub_backend.lib.state_store import setup as setup_state_store
from homehub_backend.lib.rpc import setup as setup_rpc
from homehub_backend.lib.services import setup as setup_services
from homehub_backend.lib.websocket import setup as setup_websocket
@@ -49,13 +49,14 @@ async def app_on_startup(app):
def create_app(loop=None):
app = web.Application(logger=app_logger, loop=loop)
app['SETTINGS'] = settings
application_class = getattr(
settings, 'APPLICATION_CLASS', HomeHubApplication,
)
app = application_class(settings, logger=app_logger, loop=loop)
app.on_startup.append(app_on_startup)
setup_aiojobs(app)
setup_rpc(app)
setup_state_store(app)
setup_services(app)
setup_websocket(app)
@@ -67,13 +68,15 @@ def create_app(loop=None):
app['INDEX_HTML'] = index_html_f.read()
app.add_routes([
web.post('/backend/rpc', app.get_jsonrpc_view()),
web.get('/', handlers.get_index),
web.get('/index.html', handlers.get_index),
])
if os.path.isdir(frontend_dir):
static_path = getattr(settings, 'STATIC_PATH', 'frontend')
app.add_routes([
web.static('/frontend', frontend_dir),
web.static(f'/{static_path}', frontend_dir),
])
return app

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
import logging
from aiohttp import web
from bthlabs_jsonrpc_aiohttp import JSONRPCView
LOGGER = logging.getLogger('zagony_bot.lib.application')
class HomeHubJSONRPCView(JSONRPCView):
PUBLIC_METHODS = (
'services.get_states', 'services.start', 'state.get_frontend',
'system.list_methods',
)
async def can_call(self, request, method, args, kwargs):
result = method in self.PUBLIC_METHODS
if request.app['SETTINGS'].ADMIN_HOSTS == '*':
result = True
if request.host in request.app['SETTINGS'].ADMIN_HOSTS:
result = True
if result is False:
LOGGER.warning(
'HomeHubJSONRPCView.can_call(): Access denied! host=%s method=%s',
request.host,
method,
)
return result
class HomeHubApplication(web.Application):
def __init__(self, settings, *args, **kwargs):
super().__init__(*args, **kwargs)
self['SETTINGS'] = settings
def get_jsonrpc_view(self):
return HomeHubJSONRPCView()

View File

@@ -1,175 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from types import MethodType
from aiohttp import web
LOGGER = logging.getLogger('homehub.lib.rpc')
class BaseJSONRPCError(Exception):
def __init__(self, data=None):
self.data = data
def to_json(self):
result = {
'code': self.ERROR_CODE,
'message': self.ERROR_MESSAGE,
}
if self.data:
result['data'] = self.data
return result
class JSONRPCParseError(BaseJSONRPCError):
ERROR_CODE = -32700
ERROR_MESSAGE = 'Parse error'
class JSONRPCInvalidRequestError(BaseJSONRPCError):
ERROR_CODE = -32600
ERROR_MESSAGE = 'Invalid Request'
class JSONRPCMethodNotFoundError(BaseJSONRPCError):
ERROR_CODE = -32601
ERROR_MESSAGE = 'Method not found'
class JSONRPCInvalidParamsError(BaseJSONRPCError):
ERROR_CODE = -32602
ERROR_MESSAGE = 'Invalid params'
class JSONRPCInternalErrorError(BaseJSONRPCError):
ERROR_CODE = -32603
ERROR_MESSAGE = 'Internal error'
async def post_rpc(request):
responses = []
results = []
calls = []
try:
try:
data = await request.json()
except Exception as exc:
LOGGER.error('Error deserializing RPC call!', exc_info=exc)
raise JSONRPCParseError()
if isinstance(data, list):
calls = data
else:
calls.append(data)
if len(calls) == 0:
raise JSONRPCInvalidRequestError()
for call in calls:
call_method = None
method_handler = None
result = None
try:
try:
assert isinstance(call, dict), JSONRPCInvalidRequestError
assert call.get('jsonrpc') == '2.0', JSONRPCInvalidRequestError
call_method = call.get('method', None)
assert call_method is not None, JSONRPCInvalidRequestError
method_handler = request.config_dict['RPC_METHOD_REGISTRY'].get(call_method, None)
assert method_handler is not None, JSONRPCMethodNotFoundError
except AssertionError as exc:
klass = exc.args[0]
raise klass()
args = []
kwargs = {}
call_params = call.get('params', None)
if call_params is not None:
if isinstance(call_params, list):
args = call_params
elif isinstance(call_params, dict):
kwargs = call_params
else:
raise JSONRPCInvalidParamsError()
args.insert(0, request)
try:
result = await method_handler(*args, **kwargs)
except TypeError as exc:
LOGGER.error(
f'Error handling RPC method: {call_method}!',
exc_info=exc,
)
raise JSONRPCInvalidParamsError(str(exc))
except Exception as exc:
if isinstance(exc, BaseJSONRPCError):
result = exc
else:
LOGGER.error(
f'Error handling RPC method: {call_method}!',
exc_info=exc,
)
result = JSONRPCInternalErrorError(str(exc))
finally:
results.append((call, result))
except Exception as exc:
if isinstance(exc, BaseJSONRPCError):
results = [(None, exc)]
else:
raise
for result in results:
call, call_result = result
response = {
'jsonrpc': '2.0',
'id': None,
}
if isinstance(call, dict) and call.get('id', None) is not None:
response['id'] = call['id']
if isinstance(call_result, BaseJSONRPCError):
response['error'] = call_result.to_json()
else:
response.update({
'method': call['method'],
'result': call_result,
})
if 'error' in response or response['id'] is not None:
responses.append(response)
if len(responses) == 1:
return web.json_response(responses[0])
return web.json_response(responses)
async def list_methods(request):
return sorted(request.config_dict['RPC_METHOD_REGISTRY'].keys())
def add_rpc_methods(app, methods):
for name, method in methods:
app['RPC_METHOD_REGISTRY'][name] = method
def setup(app):
app['RPC_METHOD_REGISTRY'] = {}
app.add_rpc_methods = MethodType(add_rpc_methods, app)
app.add_routes([
web.post('/backend/rpc', post_rpc),
])
app.add_rpc_methods([
('system.list_methods', list_methods),
])

View File

@@ -1,10 +1,18 @@
# -*- coding: utf-8 -*-
import asyncio
from collections import namedtuple
import copy
import logging
from aiohttp import web
from bthlabs_jsonrpc_core import register_method
LOGGER = logging.getLogger('homehub.lib.services')
CapabilitySpec = namedtuple('CapabilitySpec', [
'handler', 'public', 'public_content_type',
])
class BaseServiceError(Exception):
pass
@@ -42,6 +50,7 @@ class BaseService:
self.instance = instance
self.characteristics = characteristics
self.capabilities = {}
self.state = self.app['STATE'].get()['backend'].get(
self.state_key(), None,
)
@@ -104,6 +113,15 @@ class BaseService:
self.app['STATE'].save(new_state)
def register_capability(self,
name,
handler,
public=False,
public_content_type=None):
self.capabilities[name] = CapabilitySpec(
handler, public, public_content_type,
)
def _get_service_key(request, kind, instance):
klass = request.config_dict['SETTINGS'].SERVICES.get(kind, None)
@@ -121,6 +139,7 @@ def _lookup_service(request, kind, instance):
return request.config_dict['SERVICES'].get(service_key, None)
@register_method('services.start')
async def start_service(request, kind, instance, characteristics):
data = None
@@ -145,6 +164,7 @@ async def start_service(request, kind, instance, characteristics):
return data.data
@register_method('services.stop')
async def stop_service(request, kind, instance):
result = 'ok'
@@ -158,29 +178,83 @@ async def stop_service(request, kind, instance):
return result
async def use_service(request, kind, instance, capability, params):
def get_service_capability(request, kind, instance, capability):
result = None
service = _lookup_service(request, kind, instance)
if not service:
raise BaseServiceError(f'Unknown service: {instance} of {kind}')
else:
capability_handler = getattr(service, capability, None)
if not capability_handler:
capability_spec = service.capabilities.get(capability, None)
if not capability_spec:
raise BaseServiceError(f'Unknown capability: {kind}.{capability}')
else:
result = await capability_handler(*params)
result = capability_spec
return result
@register_method('services.use')
async def use_service(request, kind, instance, capability, params):
capability_spec = get_service_capability(
request, kind, instance, capability,
)
result = await capability_spec.handler(*params)
return result
async def web_use_service(request):
try:
kind = request.query.get('kind', None)
capability = request.query.get('capability', None)
capability_spec = get_service_capability(
request,
kind,
request.query.get('instance', None),
capability,
)
if capability_spec.public is False:
raise BaseServiceError(f'Unknown capability: {kind}.{capability}')
except BaseServiceError as exception:
LOGGER.error(
'web_use_service(): Capability lookup error.', exc_info=exception,
)
response_body = 'Bad Request'
is_debug_or_testing = any((
request.config_dict['SETTINGS'].DEBUG,
request.config_dict['SETTINGS'].TESTING,
))
if is_debug_or_testing is True:
response_body = exception.args[0]
return web.Response(
body=response_body,
content_type='text/plain',
charset='utf-8',
status=400,
)
content_type = capability_spec.public_content_type
params = request.query.getall('params', [])
result = await capability_spec.handler(*params)
if isinstance(result, str):
result = result.encode('utf-8')
return web.Response(
body=result, content_type=content_type, charset='utf-8',
)
def setup(app):
app['SERVICES'] = {}
app.add_rpc_methods([
('services.start', start_service),
('services.stop', stop_service),
('services.use', use_service),
app.add_routes([
web.get('/services/use', web_use_service),
])
async def on_cleanup(app):

View File

@@ -6,6 +6,8 @@ import json
import logging
import os
from bthlabs_jsonrpc_core import register_method
LOGGER = logging.getLogger('homehub.lib.state_store')
@@ -42,10 +44,12 @@ class StateStore:
self.threadpool.submit(self.do_save, state)
@register_method('state.get_frontend')
async def get_frontend_state(request):
return request.config_dict['STATE'].get()['frontend']
@register_method('state.save_frontend')
async def save_frontend_state(request, new_state):
state = copy.deepcopy(request.config_dict['STATE'].get())
state['frontend'] = new_state
@@ -66,9 +70,4 @@ def setup(app):
app.on_startup.append(store.on_app_startup)
app.on_cleanup.append(store.on_app_cleanup)
app.add_rpc_methods([
('state.get_frontend', get_frontend_state),
('state.save_frontend', save_frontend_state),
])
app['STATE'] = store

View File

@@ -6,5 +6,6 @@ from homehub_backend.services import SERVICES # noqa: F401
DEBUG = False
TESTING = False
STATE_PATH = os.path.join(os.getcwd(), 'state.json')
ADMIN_HOSTS = '*'
WEATHER_SERVICE_API_KEY = None

View File

@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
import importlib
from homehub_backend.app import create_app
@@ -13,9 +15,6 @@ async def fial(request):
def create_testing_app(loop=None):
app = create_app(loop=None)
app.add_rpc_methods([
('testing.greet', greet),
('testing.fial', fial),
])
importlib.reload(app['SETTINGS'])
return app

View File

@@ -6,6 +6,7 @@ from homehub_backend.testing import services
DEBUG = False
TESTING = True
STATE_PATH = os.path.join(os.path.dirname(__file__), 'state.json')
ADMIN_HOSTS = '*'
WEATHER_SERVICE_API_KEY = 'thisisntright'

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from homehub_backend.app import settings
from homehub_backend.lib import application
class Test_HomeHubApplication:
def test_init(self):
# When
result = application.HomeHubApplication(settings)
# Then
assert result['SETTINGS'] == settings
def test_get_jsonrpc_view(self):
# Given
homehub_application = application.HomeHubApplication(settings)
# When
result = homehub_application.get_jsonrpc_view()
# Then
assert isinstance(result, application.HomeHubJSONRPCView)

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from unittest import mock
from aiohttp.web import Request
import pytest
from homehub_backend.lib import application
@pytest.fixture
def fake_request(homehub_app):
result = mock.Mock(spec=Request)
result.app = homehub_app
result.host = 'homehub.test'
return result
class Test_HomeHubJSONRPCView:
async def test_can_call_public_method(self, fake_request):
# Given
jsonrpc_view = application.HomeHubJSONRPCView()
# When
result = await jsonrpc_view.can_call(
fake_request, 'system.list_methods', [], {},
)
# Then
assert result is True
async def test_can_call_admin_hosts_wildcard(self, fake_request):
# Given
fake_request.app['SETTINGS'].ADMIN_HOSTS = '*'
jsonrpc_view = application.HomeHubJSONRPCView()
# When
result = await jsonrpc_view.can_call(
fake_request, 'services.stop', [], {},
)
# Then
assert result is True
async def test_can_call_host_in_admin_hosts(self, fake_request):
# Given
fake_request.app['SETTINGS'].ADMIN_HOSTS = ['homehub.test']
jsonrpc_view = application.HomeHubJSONRPCView()
# When
result = await jsonrpc_view.can_call(
fake_request, 'services.stop', [], {},
)
# Then
assert result is True
async def test_can_call_False(self, fake_request):
# Given
fake_request.app['SETTINGS'].ADMIN_HOSTS = ['admin.homehub.test']
jsonrpc_view = application.HomeHubJSONRPCView()
# When
result = await jsonrpc_view.can_call(
fake_request, 'services.stop', [], {},
)
# Then
assert result is False

View File

@@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
from homehub_backend.lib import rpc
class Test_BaseJSONRPCError:
def test_to_json(self):
exception = rpc.BaseJSONRPCError(data='spam')
exception.ERROR_CODE = -32604
exception.ERROR_MESSAGE = 'Test error'
result = exception.to_json()
assert result['code'] == exception.ERROR_CODE
assert result['message'] == exception.ERROR_MESSAGE
assert result['data'] == exception.data
def test_to_json_without_data(self):
exception = rpc.BaseJSONRPCError()
exception.ERROR_CODE = -32604
exception.ERROR_MESSAGE = 'Test error'
result = exception.to_json()
assert result['code'] == exception.ERROR_CODE
assert result['message'] == exception.ERROR_MESSAGE
assert 'data' not in result

View File

@@ -1,182 +0,0 @@
# -*- coding: utf-8 -*-
from homehub_backend.lib import rpc
class Test_PostRPC:
def _valid_call(self, **kwargs):
result = {
'jsonrpc': '2.0',
'id': 'testing',
'method': 'testing.greet',
}
result.update(kwargs)
return result
async def test_parse_error(self, homehub_client):
response = await homehub_client.post('/backend/rpc', data=b'spam')
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCParseError.ERROR_CODE
async def test_empty_call(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_empty_batch(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json=[])
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_call_not_dict(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json='"spam"')
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_call_without_jsonrpc(self, homehub_client):
call = self._valid_call()
call.pop('jsonrpc')
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_call_without_method(self, homehub_client):
call = self._valid_call()
call.pop('method')
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_method_not_found(self, homehub_client):
call = self._valid_call()
call['method'] = 'testing.idontexist'
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCMethodNotFoundError.ERROR_CODE
async def test_invalid_params(self, homehub_client):
call = self._valid_call()
call['params'] = 'spam'
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidParamsError.ERROR_CODE
assert 'data' not in data['error']
async def test_call_with_method_params_error(self, homehub_client):
call = self._valid_call()
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidParamsError.ERROR_CODE
assert data['error']['data'] == (
"greet() missing 1 required positional argument: 'who'"
)
async def test_method_exception(self, homehub_client):
call = self._valid_call()
call['method'] = 'testing.fial'
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInternalErrorError.ERROR_CODE
assert data['error']['data'] == 'FIAL'
async def test_call_with_args(self, homehub_client):
call = self._valid_call()
call['params'] = ['aiohttp-jsonrpc']
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'error' not in data
assert data['result'] == 'Hello, aiohttp-jsonrpc!'
async def test_call_with_kwargs(self, homehub_client):
call = self._valid_call()
call['params'] = {
'who': 'aiohttp-jsonrpc',
'how': 'Hi,',
}
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'error' not in data
assert data['result'] == 'Hi, aiohttp-jsonrpc!'
async def test_result(self, homehub_client):
call = self._valid_call()
call['params'] = ['aiohttp-jsonrpc']
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'error' not in data
assert data['jsonrpc'] == call['jsonrpc']
assert data['id'] == call['id']
assert data['method'] == call['method']
async def test_batch(self, homehub_client):
call_without_id = self._valid_call(params=['World'])
call_without_id.pop('id')
batch = [
self._valid_call(params=['aiohttp-jsonrpc']),
self._valid_call(id='testing2', method='testing.fial', params=[]),
call_without_id,
]
response = await homehub_client.post('/backend/rpc', json=batch)
assert response.status == 200
data = await response.json()
assert isinstance(data, list) is True
assert len(data) == 2
first_result, second_result = data
assert first_result['id'] == batch[0]['id']
assert 'error' not in first_result
assert first_result['result'] == 'Hello, aiohttp-jsonrpc!'
assert second_result['id'] == batch[1]['id']
assert 'result' not in second_result
assert second_result['error']['code'] == rpc.JSONRPCInternalErrorError.ERROR_CODE
assert second_result['error']['data'] == 'FIAL'

View File

@@ -24,6 +24,7 @@ class Test_BaseService:
assert service.app == app
assert service.instance == instance
assert service.characteristics == characteristics
assert service.capabilities == {}
assert service.state == 'fake_state'
@mock.patch.object(app['STATE'], 'get')
@@ -202,3 +203,34 @@ class Test_BaseService:
'testing.FakeService.fake_instance': 'spam',
},
})
def test_register_capability(self):
service = FakeService(app, 'fake_instance', {})
service.register_capability('capability', service.capability)
expected_capabilities = {
'capability': services.CapabilitySpec(
service.capability, False, None,
),
}
assert service.capabilities == expected_capabilities
def test_register_capability_with_public(self):
service = FakeService(app, 'fake_instance', {})
service.register_capability(
'capability',
service.capability,
public=True,
public_content_type='text/plain',
)
assert len(service.capabilities) == 1
expected_capabilities = {
'capability': services.CapabilitySpec(
service.capability, True, 'text/plain',
),
}
assert service.capabilities == expected_capabilities

View File

@@ -66,6 +66,8 @@ class Test_UseService:
service = testing_services.FakeService(
homehub_client.server.app, 'fake_instance', {},
)
service.register_capability('capability', service.capability)
homehub_client.server.app['SERVICES'].update({
'testing.FakeService.fake_instance': service,
})

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
from homehub_backend.testing import services as testing_services
class Test_WebUseService:
async def test_unknown_service_instance(self, homehub_client):
response = await homehub_client.get('/services/use', params={
'kind': 'testing.FakeService',
'instance': 'idontexist',
'capability': 'capability',
'params': 'It works!',
})
assert response.status == 400
data = await response.text()
assert data == 'Unknown service: idontexist of testing.FakeService'
async def test_unknown_service_kind(self, homehub_client):
response = await homehub_client.get('/services/use', params={
'kind': 'IDontExist',
'instance': 'fake_instance',
'capability': 'capability',
'params': 'It works!',
})
assert response.status == 400
data = await response.text()
assert data == 'Unknown service: IDontExist'
async def test_unknown_capability(self, homehub_client):
service = testing_services.FakeService(
homehub_client.server.app, 'fake_instance', {},
)
homehub_client.server.app['SERVICES'].update({
'testing.FakeService.fake_instance': service,
})
response = await homehub_client.get('/services/use', params={
'kind': 'testing.FakeService',
'instance': 'fake_instance',
'capability': 'idontexist',
'params': 'It works!',
})
assert response.status == 400
data = await response.text()
assert data == 'Unknown capability: testing.FakeService.idontexist'
async def test_capability_not_public(self, homehub_client):
service = testing_services.FakeService(
homehub_client.server.app, 'fake_instance', {},
)
service.register_capability('capability', service.capability)
homehub_client.server.app['SERVICES'].update({
'testing.FakeService.fake_instance': service,
})
response = await homehub_client.get('/services/use', params={
'kind': 'testing.FakeService',
'instance': 'fake_instance',
'capability': 'capability',
'params': 'It works!',
})
assert response.status == 400
data = await response.text()
assert data == 'Unknown capability: testing.FakeService.capability'
async def test_ok(self, homehub_client):
service = testing_services.FakeService(
homehub_client.server.app, 'fake_instance', {},
)
service.register_capability(
'capability',
service.capability,
public=True,
public_content_type='text/plain',
)
homehub_client.server.app['SERVICES'].update({
'testing.FakeService.fake_instance': service,
})
response = await homehub_client.get('/services/use', params={
'kind': 'testing.FakeService',
'instance': 'fake_instance',
'capability': 'capability',
'params': 'It works!',
})
assert response.status == 200
data = await response.text()
assert data == 'It works!'

View File

@@ -1,10 +1,10 @@
-r requirements.txt
aiohttp-devtools==0.13.1
aiohttp-devtools==1.0.post0
flake8==3.8.3
flake8-commas==2.0.0
pycodestyle==2.6.0
pytest==6.0.1
pytest-aiohttp==0.3.0
pytest-asyncio==0.14.0
pytest==7.1.2
pytest-aiohttp==1.0.4
pytest-asyncio==0.18.3
pytest-env==0.6.2
twine==2.0.0

View File

@@ -1,2 +1,3 @@
aiohttp==3.6.2
aiojobs==0.2.2
aiohttp==3.8.1
aiojobs==1.0.0
bthlabs-jsonrpc-aiohttp==1.0.0

View File

@@ -52,6 +52,6 @@ setup(
],
packages=packages,
include_package_data=True,
python_requires='>=3.7',
python_requires='>=3.10',
install_requires=requirements,
)