From 56742ad3787bebc6fd03497bb54d57beb4d137b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Srokosz?= Date: Wed, 21 Feb 2024 16:16:45 +0100 Subject: [PATCH] Refactor: replace Flask-Restful with own, simple implementation --- mwdb/core/apispec_utils.py | 91 ------------------ mwdb/core/service.py | 168 +++++++++++++++++++--------------- mwdb/core/util.py | 17 ---- mwdb/resources/api_key.py | 2 +- mwdb/resources/attribute.py | 2 +- mwdb/resources/auth.py | 2 +- mwdb/resources/comment.py | 2 +- mwdb/resources/config.py | 2 +- mwdb/resources/download.py | 2 +- mwdb/resources/file.py | 2 +- mwdb/resources/group.py | 2 +- mwdb/resources/karton.py | 2 +- mwdb/resources/metakey.py | 2 +- mwdb/resources/metrics.py | 2 +- mwdb/resources/oauth.py | 2 +- mwdb/resources/object.py | 3 +- mwdb/resources/quick_query.py | 2 +- mwdb/resources/relations.py | 2 +- mwdb/resources/remotes.py | 2 +- mwdb/resources/search.py | 3 +- mwdb/resources/server.py | 2 +- mwdb/resources/share.py | 2 +- mwdb/resources/tag.py | 2 +- mwdb/resources/user.py | 2 +- requirements.txt | 8 +- 25 files changed, 120 insertions(+), 208 deletions(-) delete mode 100644 mwdb/core/apispec_utils.py diff --git a/mwdb/core/apispec_utils.py b/mwdb/core/apispec_utils.py deleted file mode 100644 index 563a82c7c..000000000 --- a/mwdb/core/apispec_utils.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Based apispec-flask-restful supporting apispec>=1.0 -psrok1 @ 2019 - -Original apispec-flask restful-plugin: - Copyright (c) 2017 theirix - https://github.com/theirix/apispec-flask-restful -""" - -import logging -import re - -from apispec import BasePlugin, yaml_utils -from apispec.exceptions import APISpecError - - -class ApispecFlaskRestful(BasePlugin): - def init_spec(self, spec): - super(ApispecFlaskRestful, self).init_spec(spec) - - def path_helper(self, path=None, operations=None, **kwargs): - try: - resource = kwargs.pop("resource") - path = deduce_path(resource, **kwargs) - # normalize path - path = re.sub(r"<(?:[^:<>]+:)?([^<>]+)>", r"{\1}", path) - return path - except Exception as exc: - logging.getLogger(__name__).exception("Exception parsing APISpec %s", exc) - raise - - def operation_helper(self, path=None, operations=None, **kwargs): - try: - resource = kwargs.pop("resource") - operations = parse_operations(resource, operations) - return operations - except Exception as exc: - logging.getLogger(__name__).exception("Exception parsing APISpec %s", exc) - raise - - -def deduce_path(resource, **kwargs): - """Find resource path using provided API or path itself""" - api = kwargs.get("api", None) - if not api: - # flask-restful resource url passed - return kwargs.get("path").path - - # flask-restful API passed - # Require MethodView - if not getattr(resource, "endpoint", None): - raise APISpecError("Flask-RESTful resource needed") - - if api.blueprint: - # it is required to have Flask app to be able enumerate routes - app = kwargs.get("app") - if app: - for rule in app.url_map.iter_rules(): - if rule.endpoint.endswith("." + resource.endpoint): - break - else: - raise APISpecError( - "Cannot find blueprint resource {}".format(resource.endpoint) - ) - else: - # Application not initialized yet, fallback to path - return kwargs.get("path").path - - else: - for rule in api.app.url_map.iter_rules(): - if rule.endpoint == resource.endpoint: - rule.endpoint.endswith("." + resource.endpoint) - break - else: - raise APISpecError("Cannot find resource {}".format(resource.endpoint)) - - return rule.rule - - -def parse_operations(resource, operations): - """Parse operations for each method in a flask-restful resource""" - for method in resource.methods: - docstring = getattr(resource, method.lower()).__doc__ - if docstring: - operation = yaml_utils.load_yaml_from_docstring(docstring) - if not operation: - logging.getLogger(__name__).warning( - "Cannot load docstring for {}/{}".format(resource, method) - ) - operations[method.lower()] = operation or dict() - return operations diff --git a/mwdb/core/service.py b/mwdb/core/service.py index 15ae1b54c..be6228e6f 100644 --- a/mwdb/core/service.py +++ b/mwdb/core/service.py @@ -1,97 +1,117 @@ +import sys import textwrap -from functools import partial from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from flask_restful import Api +from apispec_webframeworks.flask import FlaskPlugin +from flask import Blueprint, Flask, jsonify, request +from flask.typing import ResponseReturnValue from sqlalchemy.exc import OperationalError -from werkzeug.exceptions import HTTPException, ServiceUnavailable +from werkzeug.exceptions import ( + HTTPException, + InternalServerError, + MethodNotAllowed, + ServiceUnavailable, +) +from werkzeug.wrappers import Response from mwdb.version import app_version -from . import log -from .apispec_utils import ApispecFlaskRestful +from .log import getLogger +SUPPORTED_METHODS = ["head", "get", "post", "put", "delete", "patch"] -class Service(Api): - def __init__(self, flask_app, *args, **kwargs): - self.spec = self._create_spec() - self.flask_app = flask_app - super().__init__(*args, **kwargs) +logger = getLogger() - def _init_app(self, app): - # I want to log exceptions on my own - def dont_log(*_, **__): - pass - app.log_exception = dont_log - if ( - isinstance(app.handle_exception, partial) - and app.handle_exception.func is self.error_router - ): - # Prevent double-initialization - return - super()._init_app(app) +class Resource: + def __init__(self): + self.available_methods = [ + method.upper() for method in SUPPORTED_METHODS if hasattr(self, method) + ] + + def __call__(self, *args, **kwargs): + """ + Acts as view function, calling appropriate method and + jsonifying response + """ + if request.method not in self.available_methods: + raise MethodNotAllowed( + valid_methods=self.available_methods, + description="Method is not allowed for this endpoint", + ) + method = request.method.lower() + response = getattr(self, method)(*args, **kwargs) + if isinstance(response, Response): + return response + return jsonify(response) + + def get_methods(self): + """ + Returns available methods for this resource + """ + return [getattr(self, method) for method in self.available_methods] - def _create_spec(self): - spec = APISpec( - title="MWDB", + +class Service: + def __init__(self, app: Flask, blueprint: Blueprint) -> None: + self.app = app + self.blueprint = blueprint + self.blueprint.register_error_handler(Exception, self.error_handler) + self.spec = APISpec( + title="MWDB Core", version=app_version, openapi_version="3.0.2", - plugins=[ApispecFlaskRestful(), MarshmallowPlugin()], - ) + plugins=[FlaskPlugin(), MarshmallowPlugin()], + info={ + "description": textwrap.dedent( + """ + MWDB API documentation. - spec.components.security_scheme( + If you want to automate things, we recommend using + + mwdblib library + + """ + ) + }, + servers=[ + { + "url": "{scheme}://{host}", + "description": "MWDB API endpoint", + "variables": { + "scheme": {"enum": ["http", "https"], "default": "https"}, + "host": {"default": "mwdb.cert.pl"}, + }, + } + ], + ) + self.spec.components.security_scheme( "bearerAuth", {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} ) - spec.options["info"] = { - "description": textwrap.dedent( - """ - MWDB API documentation. - If you want to automate things, we recommend using - mwdblib library""" - ) - } - spec.options["servers"] = [ - { - "url": "{scheme}://{host}", - "description": "MWDB API endpoint", - "variables": { - "scheme": {"enum": ["http", "https"], "default": "https"}, - "host": {"default": "mwdb.cert.pl"}, - }, - } - ] - return spec + def _make_error_response(self, exc: HTTPException) -> ResponseReturnValue: + return jsonify({"message": exc.description}), exc.code - def error_router(self, original_handler, e): - logger = log.getLogger() - if isinstance(e, HTTPException): - logger.error(str(e)) - elif isinstance(e, OperationalError): - logger.error(str(e)) - raise ServiceUnavailable("Request canceled due to statement timeout") + def error_handler(self, exc: Exception) -> ResponseReturnValue: + if isinstance(exc, HTTPException): + return self._make_error_response(exc) + elif isinstance(exc, OperationalError): + return self._make_error_response( + ServiceUnavailable("Request canceled due to statement timeout") + ) else: - logger.exception("Unhandled exception occurred") - - # Handle all exceptions using handle_error, not only for owned routes - try: - return self.handle_error(e) - except Exception: - logger.exception("Exception from handle_error occurred") - pass - # If something went wrong - fallback to original behavior - return super().error_router(original_handler, e) + # Unknown exception, return ISE 500 + logger.exception("Internal server error", exc_info=sys.exc_info()) + return self._make_error_response( + InternalServerError("Internal server error") + ) - def add_resource(self, resource, *urls, undocumented=False, **kwargs): - super().add_resource(resource, *urls, **kwargs) + def add_resource( + self, resource: Resource, *urls: str, undocumented: bool = False + ) -> None: + for url in urls: + endpoint = f"{self.blueprint.name}.{resource.__name__.lower()}" + self.blueprint.add_url_rule(rule=url, endpoint=endpoint, view_func=resource) if not undocumented: - self.spec.path(resource=resource, api=self, app=self.flask_app) - - def relative_url_for(self, resource, **values): - path = self.url_for(resource, **values) - return path[len(self.blueprint.url_prefix) :] - - def endpoint_for(self, resource): - return f"{self.blueprint.name}.{resource}" + self.spec.path(view=resource, app=self.app) diff --git a/mwdb/core/util.py b/mwdb/core/util.py index 79d46400d..55ea86bc9 100644 --- a/mwdb/core/util.py +++ b/mwdb/core/util.py @@ -22,8 +22,6 @@ def token_hex(nbytes=None): InstanceMetadataFetcher, InstanceMetadataProvider, ) -from flask_restful import abort -from flask_sqlalchemy import Pagination def config_dhash(obj): @@ -130,21 +128,6 @@ def calc_crc32(stream): return "{:08x}".format(csum) -def paginate_fast(q, page, per_page): - if page < 1: - abort(404) - - if per_page < 0: - abort(404) - - items = q.limit(per_page).offset((page - 1) * per_page).all() - - if not items and page != 1: - abort(404) - - return Pagination(q, page, per_page, 0, items) - - def is_true(flag): # "True", "true", "1" if isinstance(flag, str) and flag and flag.lower() in ["true", "1"]: diff --git a/mwdb/resources/api_key.py b/mwdb/resources/api_key.py index 0c3710bb0..38bde73cd 100644 --- a/mwdb/resources/api_key.py +++ b/mwdb/resources/api_key.py @@ -2,11 +2,11 @@ from datetime import datetime from flask import g, request -from flask_restful import Resource from sqlalchemy.orm.exc import NoResultFound from werkzeug.exceptions import Forbidden, NotFound from mwdb.core.capabilities import Capabilities +from mwdb.core.service import Resource from mwdb.model import APIKey, User, db from mwdb.schema.api_key import ( APIKeyIdentifierBase, diff --git a/mwdb/resources/attribute.py b/mwdb/resources/attribute.py index 59ac4e61b..4bc8ddc2a 100644 --- a/mwdb/resources/attribute.py +++ b/mwdb/resources/attribute.py @@ -1,9 +1,9 @@ from flask import g, request -from flask_restful import Resource from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Attribute, AttributeDefinition, AttributePermission, Group, db from mwdb.schema.attribute import ( AttributeDefinitionCreateRequestSchema, diff --git a/mwdb/resources/auth.py b/mwdb/resources/auth.py index 44b6a12df..a55d62047 100644 --- a/mwdb/resources/auth.py +++ b/mwdb/resources/auth.py @@ -2,7 +2,6 @@ import requests from flask import g, request -from flask_restful import Resource from sqlalchemy import exists, func from sqlalchemy.orm import joinedload from sqlalchemy.orm.exc import NoResultFound @@ -13,6 +12,7 @@ from mwdb.core.config import app_config from mwdb.core.mail import MailError, send_email_notification from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Group, Member, User, db from mwdb.schema.auth import ( AuthLoginRequestSchema, diff --git a/mwdb/resources/comment.py b/mwdb/resources/comment.py index e37daeaad..a6650e792 100644 --- a/mwdb/resources/comment.py +++ b/mwdb/resources/comment.py @@ -1,9 +1,9 @@ from flask import g, request -from flask_restful import Resource from werkzeug.exceptions import NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Comment, db from mwdb.schema.comment import CommentItemResponseSchema, CommentRequestSchema diff --git a/mwdb/resources/config.py b/mwdb/resources/config.py index 3c90f58ca..30f53d830 100644 --- a/mwdb/resources/config.py +++ b/mwdb/resources/config.py @@ -1,13 +1,13 @@ from datetime import datetime, timedelta from flask import g, request -from flask_restful import Resource from sqlalchemy import func from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.deprecated import DeprecatedFeature, deprecated_endpoint from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Config, TextBlob, db from mwdb.model.object import ObjectTypeConflictError from mwdb.schema.blob import BlobCreateSpecSchema diff --git a/mwdb/resources/download.py b/mwdb/resources/download.py index 0306bc7cb..eef553a21 100644 --- a/mwdb/resources/download.py +++ b/mwdb/resources/download.py @@ -1,9 +1,9 @@ from flask import Response -from flask_restful import Resource from werkzeug.exceptions import Forbidden, NotFound from mwdb.core.app import api from mwdb.core.deprecated import DeprecatedFeature, deprecated_endpoint +from mwdb.core.service import Resource from mwdb.model import File from mwdb.schema.download import DownloadURLResponseSchema diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index cb4732572..fd6c33c3c 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -1,10 +1,10 @@ from flask import Response, g, request -from flask_restful import Resource from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound, Unauthorized from mwdb.core.capabilities import Capabilities from mwdb.core.deprecated import DeprecatedFeature, deprecated_endpoint from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import File from mwdb.model.file import EmptyFileError from mwdb.model.object import ObjectTypeConflictError diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index 8a5b1e478..d1e6f64d0 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -1,11 +1,11 @@ from flask import g, request -from flask_restful import Resource from sqlalchemy import exists from sqlalchemy.orm import joinedload from werkzeug.exceptions import Conflict, Forbidden, NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Group, Member, User, db from mwdb.schema.group import ( GroupCreateRequestSchema, diff --git a/mwdb/resources/karton.py b/mwdb/resources/karton.py index e6fe9534f..0061f6bd9 100644 --- a/mwdb/resources/karton.py +++ b/mwdb/resources/karton.py @@ -1,8 +1,8 @@ from flask import request -from flask_restful import Resource from werkzeug.exceptions import BadRequest, NotFound from mwdb.core.capabilities import Capabilities +from mwdb.core.service import Resource from mwdb.model import KartonAnalysis, Object from mwdb.schema.karton import ( KartonItemResponseSchema, diff --git a/mwdb/resources/metakey.py b/mwdb/resources/metakey.py index a49e8a9ca..7f8d294dc 100644 --- a/mwdb/resources/metakey.py +++ b/mwdb/resources/metakey.py @@ -1,9 +1,9 @@ from flask import g, request -from flask_restful import Resource from werkzeug.exceptions import BadRequest, Forbidden, NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.deprecated import DeprecatedFeature, deprecated_endpoint +from mwdb.core.service import Resource from mwdb.model import AttributeDefinition, AttributePermission, Group, db from mwdb.schema.metakey import ( MetakeyDefinitionItemRequestArgsSchema, diff --git a/mwdb/resources/metrics.py b/mwdb/resources/metrics.py index f49601df0..35854d91a 100644 --- a/mwdb/resources/metrics.py +++ b/mwdb/resources/metrics.py @@ -1,8 +1,8 @@ from flask import Response -from flask_restful import Resource from mwdb.core.capabilities import Capabilities from mwdb.core.metrics import collect +from mwdb.core.service import Resource from . import requires_authorization, requires_capabilities diff --git a/mwdb/resources/oauth.py b/mwdb/resources/oauth.py index 3832dbbf1..e6b62d9ab 100644 --- a/mwdb/resources/oauth.py +++ b/mwdb/resources/oauth.py @@ -2,7 +2,6 @@ import hashlib from flask import g, request -from flask_restful import Resource from marshmallow import ValidationError from sqlalchemy import and_, exists, or_ from werkzeug.exceptions import Conflict, Forbidden, NotFound @@ -10,6 +9,7 @@ from mwdb.core.capabilities import Capabilities from mwdb.core.config import app_config from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Group, OpenIDProvider, OpenIDUserIdentity, User, db from mwdb.schema.auth import AuthSuccessResponseSchema from mwdb.schema.group import GroupNameSchemaBase diff --git a/mwdb/resources/object.py b/mwdb/resources/object.py index a64990519..a24687bb9 100644 --- a/mwdb/resources/object.py +++ b/mwdb/resources/object.py @@ -2,7 +2,7 @@ from uuid import UUID from flask import g, request -from flask_restful import Resource +from luqum.parser import ParseError from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound from mwdb.core.capabilities import Capabilities @@ -10,6 +10,7 @@ from mwdb.core.deprecated import DeprecatedFeature, uses_deprecated_api from mwdb.core.plugins import hooks from mwdb.core.search import QueryBaseException, build_query +from mwdb.core.service import Resource from mwdb.model import AttributeDefinition, Object, db from mwdb.model.tag import Tag from mwdb.schema.object import ( diff --git a/mwdb/resources/quick_query.py b/mwdb/resources/quick_query.py index dbde716fd..cfde11f49 100644 --- a/mwdb/resources/quick_query.py +++ b/mwdb/resources/quick_query.py @@ -1,8 +1,8 @@ from flask import g, request -from flask_restful import Resource from werkzeug.exceptions import NotFound from mwdb.core.capabilities import Capabilities +from mwdb.core.service import Resource from mwdb.model import QuickQuery, db from mwdb.schema.quick_query import QuickQueryResponseSchema, QuickQuerySchemaBase diff --git a/mwdb/resources/relations.py b/mwdb/resources/relations.py index e8ac67b76..fe6be4134 100644 --- a/mwdb/resources/relations.py +++ b/mwdb/resources/relations.py @@ -1,8 +1,8 @@ -from flask_restful import Resource from werkzeug.exceptions import NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Object, db from mwdb.schema.relations import RelationsResponseSchema diff --git a/mwdb/resources/remotes.py b/mwdb/resources/remotes.py index 17bcd1e7d..668daf402 100644 --- a/mwdb/resources/remotes.py +++ b/mwdb/resources/remotes.py @@ -3,12 +3,12 @@ import requests from flask import Response, g, request -from flask_restful import Resource from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.config import app_config from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Config, File, TextBlob, db from mwdb.model.object import ObjectTypeConflictError from mwdb.schema.blob import BlobItemResponseSchema diff --git a/mwdb/resources/search.py b/mwdb/resources/search.py index 151167026..cf9d75f73 100644 --- a/mwdb/resources/search.py +++ b/mwdb/resources/search.py @@ -1,9 +1,10 @@ from flask import g, request -from flask_restful import Resource +from luqum.parser import ParseError from werkzeug.exceptions import BadRequest from mwdb.core.deprecated import DeprecatedFeature, deprecated_endpoint from mwdb.core.search import QueryBaseException, build_query +from mwdb.core.service import Resource from mwdb.model import Object from mwdb.schema.object import ObjectListItemResponseSchema from mwdb.schema.search import SearchRequestSchema diff --git a/mwdb/resources/server.py b/mwdb/resources/server.py index 0f4d04dde..8887f4f1b 100644 --- a/mwdb/resources/server.py +++ b/mwdb/resources/server.py @@ -1,10 +1,10 @@ from flask import g -from flask_restful import Resource from mwdb.core.app import api from mwdb.core.capabilities import Capabilities from mwdb.core.config import app_config from mwdb.core.plugins import get_plugin_info +from mwdb.core.service import Resource from mwdb.schema.server import ( ServerAdminInfoResponseSchema, ServerInfoResponseSchema, diff --git a/mwdb/resources/share.py b/mwdb/resources/share.py index cc1054f93..461458815 100644 --- a/mwdb/resources/share.py +++ b/mwdb/resources/share.py @@ -1,8 +1,8 @@ from flask import g, request -from flask_restful import Resource from werkzeug.exceptions import Forbidden, NotFound from mwdb.core.capabilities import Capabilities +from mwdb.core.service import Resource from mwdb.model import Group, User, db from mwdb.model.object import AccessType from mwdb.schema.share import ( diff --git a/mwdb/resources/tag.py b/mwdb/resources/tag.py index 9926ed17a..2ddd7b5e1 100644 --- a/mwdb/resources/tag.py +++ b/mwdb/resources/tag.py @@ -1,9 +1,9 @@ from flask import g, request -from flask_restful import Resource from werkzeug.exceptions import NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Tag, db from mwdb.schema.tag import ( TagItemResponseSchema, diff --git a/mwdb/resources/user.py b/mwdb/resources/user.py index b77f3b644..dee4fa651 100644 --- a/mwdb/resources/user.py +++ b/mwdb/resources/user.py @@ -1,7 +1,6 @@ import datetime from flask import g, request -from flask_restful import Resource from sqlalchemy import exists from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.sql.expression import true @@ -11,6 +10,7 @@ from mwdb.core.config import app_config from mwdb.core.mail import MailError, send_email_notification from mwdb.core.plugins import hooks +from mwdb.core.service import Resource from mwdb.model import Group, Member, User, db from mwdb.schema.user import ( UserCreateRequestSchema, diff --git a/requirements.txt b/requirements.txt index 706e7b9e5..10da632fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,25 +4,23 @@ alembic==1.4.2 Flask==2.3.2 Flask-SQLAlchemy==2.5.1 Flask-Migrate==3.1.0 -Flask-RESTful==0.3.9 SQLAlchemy==1.3.18 -marshmallow==3.7.1 +marshmallow==3.20.2 psycopg2-binary==2.8.5 requests==2.31.0 -apispec[yaml,validation]==3.3.1 +apispec[marshmallow]==6.4.0 +apispec-webframeworks==1.0.0 bcrypt==3.1.4 python-magic==0.4.18 luqum==0.13.0 python-json-logger==2.0.2 click==8.1.3 click-default-group==1.2.2 -PyYAML==6.0.1 redis==4.5.4 boto3==1.24.38 typed-config==1.1.0 karton-core==5.3.2 Authlib==0.15.4 -prance==0.21.8.0 # apispec unpinned dependency pyJWT==2.4.0 limits==3.9.0 python-dateutil==2.8.2