diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 4faa83b..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,101 +0,0 @@ -version: 2.1 -orbs: - docker: circleci/docker@0.5.13 - -defaults: &defaults - docker: - - image: circleci/python:3.7-stretch - environment: - - DATABASE_URL=sqlite:///db.sqlite - - CELERY_BROKER_URL=redis://redis:6379/0 - - - BOT_TOKEN=100500:abc - - MAILGUN_FROM=Note to self - - - image: redis:alpine - -jobs: - build: - <<: *defaults - steps: - - checkout - - restore_cache: - key: deps-{{ checksum "src/requirements.txt" }} - - - run: - command: | - python3 -m venv venv - . venv/bin/activate - pip install -r src/requirements.txt - - - - save_cache: - key: deps-{{ checksum "src/requirements.txt" }} - paths: - - "venv" - - - run: - name: Run Flake8 - command: | - . venv/bin/activate - flake8 - - - persist_to_workspace: - root: . - paths: - - . - - unittest: - <<: *defaults - steps: - - attach_workspace: - at: . - - - run: - command: | - . venv/bin/activate - py.test -x - - deploy: - <<: *defaults - steps: - - attach_workspace: - at: . - - run: - name: Install deploy tooling - command: | - export D_RELEASE=0.3.0 - wget -O - https://raw.githubusercontent.com/f213/d/master/install.sh|sh - - run: - name: Add host key - command: ./d add-host-key - - - run: - name: Update image - command: | - ./d update-image circle@selfmailbot.co selfmailbot f213/selfmailbot:${CIRCLE_SHA1} - - -workflows: - version: 2 - continuous-delivery: - jobs: - - build - - unittest: - requires: - - build - - docker/publish: - image: f213/selfmailbot - path: src - requires: - - unittest - filters: - branches: - only: master - - - deploy: - requires: - - docker/publish - filters: - branches: - only: master diff --git a/.circleci/known_hosts b/.circleci/known_hosts deleted file mode 100644 index 53f33c3..0000000 --- a/.circleci/known_hosts +++ /dev/null @@ -1,2 +0,0 @@ -|1|SXJBSxVNEkYfqZ/a+mvs/Z3VJFE=|D8gQTvib7/87xLUFmOmDa8aCmdI= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBI15wmRenKmX6RdYoKjiCKAAcx01Ju7wTbw8OFfGwKt2zB6LZR0zuWM4gw3Qsa5sLybeC+oO7B+L7ljBXX44x8o= -|1|a5aH/6Zvv/UIlXCfNz0nUbrQPtA=|mxHNqeRoAG0d0cmNsgmayxyVo7g= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBI15wmRenKmX6RdYoKjiCKAAcx01Ju7wTbw8OFfGwKt2zB6LZR0zuWM4gw3Qsa5sLybeC+oO7B+L7ljBXX44x8o= diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d371036 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.env +.git diff --git a/.editorconfig b/.editorconfig index 351548f..0430672 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,13 +6,8 @@ [*.py] indent_size = 4 -[*.json] - indent_style = space - indent_size = 2 - -[*.coffee] - indent_style = space - indent_size = 2 - [*.yaml] indent_size = 2 + +[{Makefile,**.mk}] + indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa26498 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +name: CI + + +on: + push: + branches: + - master + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install poetry + uses: snok/install-poetry@v1 + with: + version: 1.6.1 + + - name: Install python + id: setup-python + uses: actions/setup-python@v4 + with: + cache: 'poetry' + python-version-file: 'pyproject.toml' + + - name: make sure poetry lockfile is up to date + run: poetry check --lock && echo Lockfile is ok, $(poetry --version) + shell: bash + + - name: install deps + if: steps.setup-python.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Run the linters + run: make lint + + build-images: + needs: lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install python + id: setup-python + uses: actions/setup-python@v4 + with: + python-version-file: 'pyproject.toml' + + - name: Set up qemu + uses: docker/setup-qemu-action@v2 + + - name: Set up buildx + uses: docker/setup-buildx-action@v2 + + - name: Generate image identifier + id: image + uses: ASzc/change-string-case-action@v5 + with: + string: ${{ github.repository_owner }} + + - name: Log in to the container registry + uses: docker/login-action@v2 + if: ${{ github.ref == 'refs/heads/master' }} + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build bot image + uses: docker/build-push-action@v3 + with: + context: . + target: bot + push: ${{ github.ref == 'refs/heads/master' }} + tags: | + ghcr.io/${{ steps.image.outputs.lowercase }}/selfmailbot-bot:latest + ghcr.io/${{ steps.image.outputs.lowercase }}/selfmailbot-bot:${{ github.sha }} + + build-args: | + PYTHON_VERSION=${{ steps.setup-python.outputs.python-version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build web image + uses: docker/build-push-action@v3 + with: + context: . + target: web + push: ${{ github.ref == 'refs/heads/master' }} + tags: | + ghcr.io/${{ steps.image.outputs.lowercase }}/selfmailbot-web:latest + ghcr.io/${{ steps.image.outputs.lowercase }}/selfmailbot-web:${{ github.sha }} + + build-args: | + PYTHON_VERSION=${{ steps.setup-python.outputs.python-version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build background processing worker image + uses: docker/build-push-action@v3 + with: + context: . + target: web + push: ${{ github.ref == 'refs/heads/master' }} + tags: | + ghcr.io/${{ steps.image.outputs.lowercase }}/selfmailbot-worker:latest + ghcr.io/${{ steps.image.outputs.lowercase }}/selfmailbot-worker:${{ github.sha }} + + build-args: | + PYTHON_VERSION=${{ steps.setup-python.outputs.python-version }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2b75a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +ARG PYTHON_VERSION + +# +# Compile custom uwsgi for web image +# +FROM python:${PYTHON_VERSION}-slim-bookworm as uwsgi-compile +ENV _UWSGI_VERSION 2.0.23 +RUN apt-get update && apt-get --no-install-recommends install -y build-essential wget && rm -rf /var/lib/apt/lists/* +RUN wget -O uwsgi-${_UWSGI_VERSION}.tar.gz https://github.com/unbit/uwsgi/archive/${_UWSGI_VERSION}.tar.gz \ + && tar zxvf uwsgi-*.tar.gz \ + && UWSGI_BIN_NAME=/uwsgi make -C uwsgi-${_UWSGI_VERSION} \ + && rm -Rf uwsgi-* + + + +# +# Build poetry and export compiled dependecines as plain requirements.txt +# +FROM python:${PYTHON_VERSION}-slim-bookworm as deps-compile + +WORKDIR / +COPY poetry.lock pyproject.toml / +# Version is taken from poetry.lock, assuming it is generated with up-to-date version of poetry +RUN pip install --no-cache-dir poetry==$(cat poetry.lock |head -n1|awk -v FS='(Poetry |and)' '{print $2}') +RUN poetry export --format=requirements.txt > requirements.txt + + +FROM python:${PYTHON_VERSION}-slim-bookworm as base +LABEL maintainer="fedor@borshev.com" +RUN apt-get update \ + && apt-get -y install wget \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir --upgrade pip +COPY --from=deps-compile /requirements.txt / +RUN pip install --no-cache-dir -r requirements.txt +WORKDIR / +COPY src /src + +USER nobody + +# +# Bot image +# +FROM base as bot +ENV BOT_ENV production +HEALTHCHECK CMD wget -q -O /dev/null http://localhost:8000/healthcheck || exit 1 +CMD python -m src.bot + + +# +# Background processing image +# +FROM base as worker +HEALTHCHECK CMD celery -A src.celery inspect ping -d celery@$HOSTNAME +CMD celery -A src.celery worker -c ${CONCURENCY:-4} -n "${celery}@%h" --max-tasks-per-child ${MAX_REQUESTS_PER_CHILD:-50} --time-limit ${TIME_LIMIT:-900} + + +# +# Web image +# +FROM base as web +COPY --from=uwsgi-compile /uwsgi /usr/local/bin/ +CMD uwsgi --master --http :8000 --module src.web:app + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a8c20cb --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +fmt: + poetry run ruff format src + poetry run ruff check src --fix + poetry run toml-sort pyproject.toml + +lint: + poetry run ruff check src + poetry run mypy src + poetry run toml-sort pyproject.toml --check + +dev: + poetry run watchmedo auto-restart --directory src --patterns '*.py' --recursive -- python -- -m src.bot + +worker: + poetry run watchmedo auto-restart --directory src --patterns '*.py' --recursive -- celery -- -A src.celery worker --purge diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 8f96772..0000000 --- a/conftest.py +++ /dev/null @@ -1,172 +0,0 @@ -import base64 -import uuid -from random import randint -from unittest.mock import MagicMock - -import peewee as pw -import pytest -from faker import Faker - -faker = Faker() - - -def factory(class_name: str = None, **kwargs): - """Simple factory to create a class with attributes from kwargs""" - class FactoryGeneratedClass: - pass - - rewrite = { - '__randint': lambda *args: randint(100_000_000, 999_999_999), - } - - for key, value in kwargs.items(): - if value in rewrite: - value = rewrite[value](value) - - setattr(FactoryGeneratedClass, key, value) - - if class_name is not None: - FactoryGeneratedClass.__qualname__ = class_name - FactoryGeneratedClass.__name__ = class_name - - return FactoryGeneratedClass - - -@pytest.fixture -def db(): - return pw.SqliteDatabase(':memory:') - - -@pytest.fixture(autouse=True) -def models(db): - """Emulate the transaction -- create a new db before each test and flush it after. - - Also, return the app.models module""" - from src import models - app_models = [models.User] - - db.bind(app_models, bind_refs=False, bind_backrefs=False) - db.connect() - db.create_tables(app_models) - - yield models - - db.drop_tables(app_models) - db.close() - - -@pytest.fixture -def bot_app(bot): - """Our bot app, adds the magic curring `call` method to call it with fake bot""" - from src import app - app.call = lambda method, *args, **kwargs: getattr(app, method)(bot, *args, **kwargs) - - return app - - -@pytest.fixture -def bot(message): - """Mocked instance of the bot""" - class Bot: - send_message = MagicMock() - - return Bot() - - -@pytest.fixture -def app(bot, mocker): - mocker.patch('src.web.get_bot', return_value=bot) - from src.web import app - app.testing = True - return app - - -@pytest.fixture -def tg_user(): - """telegram.User""" - class User(factory( - 'User', - id='__randint', - is_bot=False, - first_name=faker.first_name(), - last_name=faker.last_name(), - username=faker.user_name(), - )): - - @property - def full_name(self): - return f'{self.first_name} {self.last_name}' - - return User() - - -@pytest.fixture -def db_user(models): - return lambda **kwargs: models.User.create(**{**dict( - pk=randint(100_000_000, 999_999_999), - is_confirmed=False, - email='user@e.mail', - full_name='Petrovich', - confirmation=str(uuid.uuid4()), - chat_id=randint(100_000_000, 999_999_999), - ), **kwargs}) - - -@pytest.fixture -def message(): - """telegram.Message""" - return lambda **kwargs: factory( - 'Message', - chat_id='__randint', - reply_text=MagicMock(return_value=factory(message_id=100800)()), # always 100800 as the replied message id - **kwargs, - )() - - -@pytest.fixture -def update(message, tg_user): - """telegram.Update""" - return factory( - 'Update', - update_id='__randint', - message=message(from_user=tg_user), - )() - - -@pytest.fixture -def photo(): - # 1x1 png pixel, base64 - png_b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAABHNCSVQICAgIfAhkiAAAAA1JREFUCJlj+P///38ACfsD/QjR6B4AAAAASUVORK5CYII=' - return base64.b64decode(png_b64) - - -@pytest.fixture -def tg_photo_file(photo): - """telegram.File""" - def _mock_download(custom_path=None, out=None, timeout=None): - if out: - out.write(photo) - return out - - return lambda **kwargs: factory( - 'File', - file_id='__randint', - file_size=None, - file_path='/tmp/path/to/file.png', - download=MagicMock(side_effect=_mock_download), - **kwargs, - )() - - -@pytest.fixture -def tg_photo_size(tg_photo_file): - """telegram.PhotoSize""" - return lambda **kwargs: factory( - 'PhotoSize', - file_id='__randint', - width=1, - height=1, - download=MagicMock(return_value=tg_photo_file()), - get_file=MagicMock(return_value=tg_photo_file()), - **kwargs, - )() diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..1910f1e --- /dev/null +++ b/mypy.ini @@ -0,0 +1,37 @@ +[mypy] +python_version = 3.12 + +warn_no_return = off +explicit_package_bases = on +warn_unused_configs = on +warn_unused_ignores = on +warn_redundant_casts = on +no_implicit_optional = on +no_implicit_reexport = on +strict_equality = on +warn_unreachable = on +disallow_untyped_calls = on +disallow_untyped_defs = on +disable_error_code = override + +[mypy-celery.*] +ignore_missing_imports = on + +[mypy-kombu.*] +ignore_missing_imports = on + +[mypy-envparse.*] +ignore_missing_imports = on + +[mypy-pystmark.*] +ignore_missing_imports = on + +[mypy-peewee.*] +ignore_missing_imports = on + +[mypy-playhouse.*] +ignore_missing_imports = on + +[mypy-tests.*] +disallow_untyped_defs = off + diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..90feafa --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1245 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "amqp" +version = "5.2.0" +description = "Low-level AMQP client for Python (fork of amqplib)." +optional = false +python-versions = ">=3.6" +files = [ + {file = "amqp-5.2.0-py3-none-any.whl", hash = "sha256:827cb12fb0baa892aad844fd95258143bce4027fdac4fccddbc43330fd281637"}, + {file = "amqp-5.2.0.tar.gz", hash = "sha256:a1ecff425ad063ad42a486c902807d1482311481c8ad95a72694b2975e75f7fd"}, +] + +[package.dependencies] +vine = ">=5.0.0,<6.0.0" + +[[package]] +name = "anyio" +version = "4.2.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"}, + {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "asgiref" +version = "3.7.2" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.7" +files = [ + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, +] + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + +[[package]] +name = "billiard" +version = "4.2.0" +description = "Python multiprocessing fork with improvements and bugfixes" +optional = false +python-versions = ">=3.7" +files = [ + {file = "billiard-4.2.0-py3-none-any.whl", hash = "sha256:07aa978b308f334ff8282bd4a746e681b3513db5c9a514cbdd810cbbdc19714d"}, + {file = "billiard-4.2.0.tar.gz", hash = "sha256:9a3c3184cb275aa17a732f93f65b20c525d3d9f253722d26a82194803ade5a2c"}, +] + +[[package]] +name = "blinker" +version = "1.7.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.8" +files = [ + {file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"}, + {file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"}, +] + +[[package]] +name = "celery" +version = "5.3.6" +description = "Distributed Task Queue." +optional = false +python-versions = ">=3.8" +files = [ + {file = "celery-5.3.6-py3-none-any.whl", hash = "sha256:9da4ea0118d232ce97dff5ed4974587fb1c0ff5c10042eb15278487cdd27d1af"}, + {file = "celery-5.3.6.tar.gz", hash = "sha256:870cc71d737c0200c397290d730344cc991d13a057534353d124c9380267aab9"}, +] + +[package.dependencies] +billiard = ">=4.2.0,<5.0" +click = ">=8.1.2,<9.0" +click-didyoumean = ">=0.3.0" +click-plugins = ">=1.1.1" +click-repl = ">=0.2.0" +kombu = ">=5.3.4,<6.0" +python-dateutil = ">=2.8.2" +tzdata = ">=2022.7" +vine = ">=5.1.0,<6.0" + +[package.extras] +arangodb = ["pyArango (>=2.0.2)"] +auth = ["cryptography (==41.0.5)"] +azureblockblob = ["azure-storage-blob (>=12.15.0)"] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +cassandra = ["cassandra-driver (>=3.25.0,<4)"] +consul = ["python-consul2 (==0.1.5)"] +cosmosdbsql = ["pydocumentdb (==2.3.5)"] +couchbase = ["couchbase (>=3.0.0)"] +couchdb = ["pycouchdb (==1.14.2)"] +django = ["Django (>=2.2.28)"] +dynamodb = ["boto3 (>=1.26.143)"] +elasticsearch = ["elastic-transport (<=8.10.0)", "elasticsearch (<=8.11.0)"] +eventlet = ["eventlet (>=0.32.0)"] +gevent = ["gevent (>=1.5.0)"] +librabbitmq = ["librabbitmq (>=2.0.0)"] +memcache = ["pylibmc (==1.6.3)"] +mongodb = ["pymongo[srv] (>=4.0.2)"] +msgpack = ["msgpack (==1.0.7)"] +pymemcache = ["python-memcached (==1.59)"] +pyro = ["pyro4 (==4.82)"] +pytest = ["pytest-celery (==0.0.0)"] +redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] +s3 = ["boto3 (>=1.26.143)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +solar = ["ephem (==4.1.5)"] +sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] +sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.3.0)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] +tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard (==0.22.0)"] + +[[package]] +name = "certifi" +version = "2023.11.17" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "click-didyoumean" +version = "0.3.0" +description = "Enables git-like *did-you-mean* feature in click" +optional = false +python-versions = ">=3.6.2,<4.0.0" +files = [ + {file = "click-didyoumean-0.3.0.tar.gz", hash = "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035"}, + {file = "click_didyoumean-0.3.0-py3-none-any.whl", hash = "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667"}, +] + +[package.dependencies] +click = ">=7" + +[[package]] +name = "click-plugins" +version = "1.1.1" +description = "An extension module for click to enable registering CLI commands via setuptools entry-points." +optional = false +python-versions = "*" +files = [ + {file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"}, + {file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"}, +] + +[package.dependencies] +click = ">=4.0" + +[package.extras] +dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] + +[[package]] +name = "click-repl" +version = "0.3.0" +description = "REPL plugin for Click" +optional = false +python-versions = ">=3.6" +files = [ + {file = "click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9"}, + {file = "click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812"}, +] + +[package.dependencies] +click = ">=7.0" +prompt-toolkit = ">=3.0.36" + +[package.extras] +testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "executing" +version = "2.0.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.5" +files = [ + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + +[[package]] +name = "flask" +version = "3.0.0" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638"}, + {file = "flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58"}, +] + +[package.dependencies] +asgiref = {version = ">=3.2", optional = true, markers = "extra == \"async\""} +blinker = ">=1.6.2" +click = ">=8.1.3" +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.2" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, + {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.23.0)"] + +[[package]] +name = "httpx" +version = "0.25.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"}, + {file = "httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "ipython" +version = "8.20.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.20.0-py3-none-any.whl", hash = "sha256:bc9716aad6f29f36c449e30821c9dd0c1c1a7b59ddcc26931685b87b4c569619"}, + {file = "ipython-8.20.0.tar.gz", hash = "sha256:2f21bd3fc1d51550c89ee3944ae04bbc7bc79e129ea0937da6e6c68bfdbf117a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" + +[package.extras] +all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.23)", "pandas", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath", "trio"] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "kombu" +version = "5.3.5" +description = "Messaging library for Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "kombu-5.3.5-py3-none-any.whl", hash = "sha256:0eac1bbb464afe6fb0924b21bf79460416d25d8abc52546d4f16cad94f789488"}, + {file = "kombu-5.3.5.tar.gz", hash = "sha256:30e470f1a6b49c70dc6f6d13c3e4cc4e178aa6c469ceb6bcd55645385fc84b93"}, +] + +[package.dependencies] +amqp = ">=5.1.1,<6.0.0" +vine = "*" + +[package.extras] +azureservicebus = ["azure-servicebus (>=7.10.0)"] +azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6.0)"] +confluentkafka = ["confluent-kafka (>=2.2.0)"] +consul = ["python-consul2"] +librabbitmq = ["librabbitmq (>=2.0.0)"] +mongodb = ["pymongo (>=4.1.1)"] +msgpack = ["msgpack"] +pyro = ["pyro4"] +qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] +redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] +sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=2.8.0)"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mypy" +version = "1.8.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + +[[package]] +name = "peewee" +version = "3.17.0" +description = "a little orm" +optional = false +python-versions = "*" +files = [ + {file = "peewee-3.17.0.tar.gz", hash = "sha256:3a56967f28a43ca7a4287f4803752aeeb1a57a08dee2e839b99868181dfb5df8"}, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "prompt-toolkit" +version = "3.0.43" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, + {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pygments" +version = "2.17.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, +] + +[package.extras] +plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pystmark" +version = "0.5.3" +description = "A Python library for the Postmark API (http://developer.postmarkapp.com/)." +optional = false +python-versions = "*" +files = [ + {file = "pystmark-0.5.3.tar.gz", hash = "sha256:4d73827f0b00dc03fade324ab682fb4af7039c53e7f648a453eafec836d12018"}, +] + +[package.dependencies] +requests = ">=1.2.0" + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.0.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, + {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-telegram-bot" +version = "20.7" +description = "We have made you a wrapper you can't refuse" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-telegram-bot-20.7.tar.gz", hash = "sha256:4f146c39de5f5e0b3723c2abedaf78046ebd30a6a49d2281ee4b3af5eb116b68"}, + {file = "python_telegram_bot-20.7-py3-none-any.whl", hash = "sha256:462326c65671c8c39e76c8c96756ee918be6797d225f8db84d2ec0f883383b8c"}, +] + +[package.dependencies] +httpx = ">=0.25.2,<0.26.0" + +[package.extras] +all = ["APScheduler (>=3.10.4,<3.11.0)", "aiolimiter (>=1.1.0,<1.2.0)", "cachetools (>=5.3.2,<5.4.0)", "cryptography (>=39.0.1)", "httpx[http2]", "httpx[socks]", "pytz (>=2018.6)", "tornado (>=6.3.3,<6.4.0)"] +callback-data = ["cachetools (>=5.3.2,<5.4.0)"] +ext = ["APScheduler (>=3.10.4,<3.11.0)", "aiolimiter (>=1.1.0,<1.2.0)", "cachetools (>=5.3.2,<5.4.0)", "pytz (>=2018.6)", "tornado (>=6.3.3,<6.4.0)"] +http2 = ["httpx[http2]"] +job-queue = ["APScheduler (>=3.10.4,<3.11.0)", "pytz (>=2018.6)"] +passport = ["cryptography (>=39.0.1)"] +rate-limiter = ["aiolimiter (>=1.1.0,<1.2.0)"] +socks = ["httpx[socks]"] +webhooks = ["tornado (>=6.3.3,<6.4.0)"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "redis" +version = "5.0.1" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.1-py3-none-any.whl", hash = "sha256:ed4802971884ae19d640775ba3b03aa2e7bd5e8fb8dfaed2decce4d0fc48391f"}, + {file = "redis-5.0.1.tar.gz", hash = "sha256:0dab495cd5753069d3bc650a0dde8a8f9edde16fc5691b689a566eda58100d0f"}, +] + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruff" +version = "0.1.13" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.1.13-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e3fd36e0d48aeac672aa850045e784673449ce619afc12823ea7868fcc41d8ba"}, + {file = "ruff-0.1.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9fb6b3b86450d4ec6a6732f9f60c4406061b6851c4b29f944f8c9d91c3611c7a"}, + {file = "ruff-0.1.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b13ba5d7156daaf3fd08b6b993360a96060500aca7e307d95ecbc5bb47a69296"}, + {file = "ruff-0.1.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ebb40442f7b531e136d334ef0851412410061e65d61ca8ce90d894a094feb22"}, + {file = "ruff-0.1.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226b517f42d59a543d6383cfe03cccf0091e3e0ed1b856c6824be03d2a75d3b6"}, + {file = "ruff-0.1.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5f0312ba1061e9b8c724e9a702d3c8621e3c6e6c2c9bd862550ab2951ac75c16"}, + {file = "ruff-0.1.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f59bcf5217c661254bd6bc42d65a6fd1a8b80c48763cb5c2293295babd945dd"}, + {file = "ruff-0.1.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6894b00495e00c27b6ba61af1fc666f17de6140345e5ef27dd6e08fb987259d"}, + {file = "ruff-0.1.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1600942485c6e66119da294c6294856b5c86fd6df591ce293e4a4cc8e72989"}, + {file = "ruff-0.1.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ee3febce7863e231a467f90e681d3d89210b900d49ce88723ce052c8761be8c7"}, + {file = "ruff-0.1.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dcaab50e278ff497ee4d1fe69b29ca0a9a47cd954bb17963628fa417933c6eb1"}, + {file = "ruff-0.1.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f57de973de4edef3ad3044d6a50c02ad9fc2dff0d88587f25f1a48e3f72edf5e"}, + {file = "ruff-0.1.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a36fa90eb12208272a858475ec43ac811ac37e91ef868759770b71bdabe27b6"}, + {file = "ruff-0.1.13-py3-none-win32.whl", hash = "sha256:a623349a505ff768dad6bd57087e2461be8db58305ebd5577bd0e98631f9ae69"}, + {file = "ruff-0.1.13-py3-none-win_amd64.whl", hash = "sha256:f988746e3c3982bea7f824c8fa318ce7f538c4dfefec99cd09c8770bd33e6539"}, + {file = "ruff-0.1.13-py3-none-win_arm64.whl", hash = "sha256:6bbbc3042075871ec17f28864808540a26f0f79a4478c357d3e3d2284e832998"}, + {file = "ruff-0.1.13.tar.gz", hash = "sha256:e261f1baed6291f434ffb1d5c6bd8051d1c2a26958072d38dfbec39b3dda7352"}, +] + +[[package]] +name = "sentry-sdk" +version = "1.39.2" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = "*" +files = [ + {file = "sentry-sdk-1.39.2.tar.gz", hash = "sha256:24c83b0b41c887d33328a9166f5950dc37ad58f01c9f2fbff6b87a6f1094170c"}, + {file = "sentry_sdk-1.39.2-py2.py3-none-any.whl", hash = "sha256:acaf597b30258fc7663063b291aa99e58f3096e91fe1e6634f4b79f9c1943e8e"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +grpcio = ["grpcio (>=1.21.1)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +loguru = ["loguru (>=0.5)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] +pure-eval = ["asttokens", "executing", "pure_eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "toml-sort" +version = "0.23.1" +description = "Toml sorting library" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "toml_sort-0.23.1-py3-none-any.whl", hash = "sha256:69ae60de9c4d67478533697eb4119092e2b30ddffe5ca09bbad3912905c935a0"}, + {file = "toml_sort-0.23.1.tar.gz", hash = "sha256:833728c48b0f8d509aecd9ae8347768ca3a9332977b32c9fd2002932f0eb9c90"}, +] + +[package.dependencies] +tomlkit = ">=0.11.2" + +[[package]] +name = "tomlkit" +version = "0.12.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, + {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, +] + +[[package]] +name = "traitlets" +version = "5.14.1" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.1-py3-none-any.whl", hash = "sha256:2e5a030e6eff91737c643231bfcf04a65b0132078dad75e4936700b213652e74"}, + {file = "traitlets-5.14.1.tar.gz", hash = "sha256:8585105b371a04b8316a43d5ce29c098575c2e477850b62b848b964f1444527e"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "typing-extensions" +version = "4.9.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, +] + +[[package]] +name = "tzdata" +version = "2023.4" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2023.4-py2.py3-none-any.whl", hash = "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3"}, + {file = "tzdata-2023.4.tar.gz", hash = "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9"}, +] + +[[package]] +name = "urllib3" +version = "2.1.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, + {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "uvicorn" +version = "0.25.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.25.0-py3-none-any.whl", hash = "sha256:ce107f5d9bd02b4636001a77a4e74aab5e1e2b146868ebbad565237145af444c"}, + {file = "uvicorn-0.25.0.tar.gz", hash = "sha256:6dddbad1d7ee0f5140aba5ec138ddc9612c5109399903828b4874c9937f009c2"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "vine" +version = "5.1.0" +description = "Python promises." +optional = false +python-versions = ">=3.6" +files = [ + {file = "vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc"}, + {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, +] + +[[package]] +name = "watchdog" +version = "3.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.7" +files = [ + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, + {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, + {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, + {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, + {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, + {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, + {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, + {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, + {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, +] + +[package.dependencies] +PyYAML = {version = ">=3.10", optional = true, markers = "extra == \"watchmedo\""} + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[[package]] +name = "werkzeug" +version = "3.0.1" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"}, + {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[metadata] +lock-version = "2.0" +python-versions = "~3.12" +content-hash = "24148c2ecbc54c9b6f67703e1ee3cff444e73ec8fcbcc80baa24e1016215164b" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8d3bb8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[tool.poetry] +authors = ["Fedor Borshev "] +description = "" +name = "selfmailbot" +readme = "README.md" +version = "2024.01.10" + +[tool.poetry.dependencies] +celery = "~5.3.6" +flask = {extras = ["async"], version = "^3.0.0"} +peewee = "~3.17.0" +pystmark = "~0.5.3" +python = "~3.12" +python-dotenv = "^1.0.0" +python-telegram-bot = "^20.7" +redis = "^5.0.1" +sentry-sdk = "~1" +uvicorn = "^0.25.0" + +[tool.poetry.group.dev.dependencies] +ipython = "^8.19.0" +mypy = "^1.8.0" +ruff = "^0.1.11" +toml-sort = "^0.23.1" +watchdog = {extras = ["watchmedo"], version = "^3.0.0"} + +[tool.ruff] +line-length = 160 + +[tool.ruff.lint] +fixable = ["ALL"] +ignore = [ + "A002", + "A003", + "ANN", + "ARG", + "COM812", + "D", + "D211", + "D213", + "EM101", + "FA102", + "ISC001", + "N818", + "PLR2004", + "PLW1508", + "PT", + "S101", + "S104", + "S701", +] +select = ["ALL"] + +[tool.ruff.per-file-ignores] +"tests/*.py" = [ + "ANN", + "D", + "PLR", + "RET", + "S", +] + +[tool.tomlsort] +all = true +in_place = true +sort_first = ["tool.poetry"] +spaces_before_inline_comment = 2 +spaces_indent_inline_array = 4 +trailing_comma_inline_array = true diff --git a/src/Dockerfile b/src/Dockerfile deleted file mode 100644 index 066e0a6..0000000 --- a/src/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM python:3.7-slim -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt -RUN apt-get update && \ - apt-get --no-install-recommends --assume-yes install build-essential -RUN pip install uwsgi - -COPY . /srv -WORKDIR / \ No newline at end of file diff --git a/src/app.py b/src/app.py deleted file mode 100644 index 3f561c1..0000000 --- a/src/app.py +++ /dev/null @@ -1,160 +0,0 @@ -import logging -from os.path import basename - -import sentry_sdk -from envparse import env -from telegram import Message, ReplyKeyboardMarkup, ReplyKeyboardRemove, Update -from telegram.ext import CommandHandler, MessageHandler, Updater -from telegram.ext.filters import BaseFilter, Filters - -from . import celery as tasks -from .helpers import download, get_subject, reply -from .models import User, create_tables, get_user_instance - -env.read_envfile() -logging.basicConfig(level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') - - -if env('SENTRY_DSN', default=None) is not None: - sentry_sdk.init(env('SENTRY_DSN')) - - -@reply -def start(bot, update: Update, user: User, render): - update.message.reply_text(text=render('hello_message')) - - -@reply -def resend(bot, update: Update, user, render): - tasks.send_confirmation_mail.delay(user.pk) - update.message.reply_text(text=render('confirmation_message_is_sent'), reply_markup=ReplyKeyboardRemove()) - - -@reply -def reset_email(bot, update: Update, user, render): - user.email = None - user.is_confirmed = False - user.save() - - update.message.reply_text(text=render('email_is_reset'), reply_markup=ReplyKeyboardRemove()) - - -@reply -def confirm_email(bot, update: Update, user, render): - key = update.message.text.strip() - if user.confirmation != key: - update.message.reply_text(text=render('confirmation_failure')) - return - - user.is_confirmed = True - user.save() - - update.message.reply_text(text=render('email_is_confirmed')) - - -@reply -def send_text_message(bot, update: Update, user: User, render, **kwargs): - text = update.message.text - subject = get_subject(text) - - update.message.reply_text(text=render('message_is_sent')) - - tasks.send_text.delay( - user_id=user.pk, - subject=subject, - text=text, - ) - - -@reply -def send_photo(bot, update: Update, user: User, render): - file = update.message.photo[-1].get_file() - photo = download(file) - subject = 'Photo note to self' - text = '' - - if update.message.caption is not None: - text = update.message.caption.strip() - if text: - subject = 'Photo: {}'.format(get_subject(text)) - - update.message.reply_text(text=render('photo_is_sent')) - - tasks.send_file.delay( - user_id=user.pk, - file=photo, - filename=basename(file.file_path), - subject=subject, - text=text, - ) - - -@reply -def send_voice(bot, update: Update, user: User, render): - update.message.reply_text(text='Sorry, voice messages are not supported right now') - - -@reply -def prompt_for_setting_email(bot, update: Update, user: User, render): - update.message.reply_text(text=render('please_send_email')) - - -@reply -def send_confirmation(bot, update: Update, user: User, render): - email = update.message.text.strip() - - if User.select().where(User.email == email): - update.message.reply_text(text=render('email_is_occupied')) - return - - user.email = email - user.save() - - tasks.send_confirmation_mail.delay(user.pk) - - update.message.reply_text(text=render('confirmation_message_is_sent')) - - -@reply -def prompt_for_confirm(bot, update: Update, user: User, render): - reply_markup = ReplyKeyboardMarkup([['Resend confirmation email'], ['Change email']]) - update.message.reply_text(render('waiting_for_confirmation'), reply_markup=reply_markup) - - -class ConfirmedUserFilter(BaseFilter): - def filter(self, message: Message): - user = get_user_instance(message.from_user, message.chat_id) - return user.is_confirmed - - -class UserWithoutEmailFilter(BaseFilter): - def filter(self, message: Message): - user = get_user_instance(message.from_user, message.chat_id) - return user.email is None - - -class NonConfirmedUserFilter(BaseFilter): - def filter(self, message: Message): - user = get_user_instance(message.from_user, message.chat_id) - return user.email is not None and user.is_confirmed is False - - -updater = Updater(token=env('BOT_TOKEN')) -dispatcher = updater.dispatcher - -dispatcher.add_handler(CommandHandler('start', start)) -dispatcher.add_handler(CommandHandler('reset', reset_email)) -dispatcher.add_handler(MessageHandler(UserWithoutEmailFilter() & Filters.text & Filters.regex('@'), send_confirmation)) # looks like email, so send confirmation to it -dispatcher.add_handler(MessageHandler(NonConfirmedUserFilter() & Filters.text & Filters.regex('Resend confirmation email'), resend)) # resend confirmation email -dispatcher.add_handler(MessageHandler(NonConfirmedUserFilter() & Filters.text & Filters.regex('Change email'), reset_email)) # change email -dispatcher.add_handler(MessageHandler(NonConfirmedUserFilter() & Filters.text & Filters.regex(r'\w{8}\-\w{4}\-\w{4}\-\w{4}\-\w{12}'), confirm_email)) # confirm email -dispatcher.add_handler(MessageHandler(UserWithoutEmailFilter(), prompt_for_setting_email)) -dispatcher.add_handler(MessageHandler(NonConfirmedUserFilter(), prompt_for_confirm)) -dispatcher.add_handler(MessageHandler(ConfirmedUserFilter() & Filters.text, send_text_message)) -dispatcher.add_handler(MessageHandler(ConfirmedUserFilter() & Filters.photo, send_photo)) -dispatcher.add_handler(MessageHandler(ConfirmedUserFilter() & Filters.voice, send_voice)) - -if __name__ == '__main__': - create_tables() - updater.start_polling() diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..6650032 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,222 @@ +import asyncio +import os +from pathlib import Path + +import kombu +import uvicorn +from asgiref.wsgi import WsgiToAsgi +from dotenv import load_dotenv +from flask import Flask, Response, request +from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update +from telegram.ext import Application, ApplicationBuilder, CommandHandler, MessageHandler, filters + +from . import celery as tasks +from .framework import reply +from .helpers import download, enable_logging, get_subject, init_sentry +from .models import User, create_tables, get_user_instance +from .t import HumanMessage, MessageUpdate, TextMessageUpdate +from .tpl import render + +load_dotenv() + + +@reply +async def start(update: TextMessageUpdate) -> None: + await update.message.reply_text( + text=render("hello_message"), + ) + + +@reply +async def reset_email(update: TextMessageUpdate, user: User) -> None: + user.email = None + user.is_confirmed = False + user.save() + + await update.message.reply_text(text=render("email_is_reset"), reply_markup=ReplyKeyboardRemove()) + + +@reply +async def confirm_email(update: TextMessageUpdate, user: User) -> None: + key = update.message.text.strip() + + if user.confirmation != key: + await update.message.reply_text(text=render("confirmation_failure")) + return + + user.is_confirmed = True + user.save() + + await update.message.reply_text(text=render("email_is_confirmed")) + + +@reply +async def send_text_message(update: TextMessageUpdate, user: User) -> None: + text = update.message.text + subject = get_subject(text) + + await update.message.reply_text(text=render("message_is_sent")) + + tasks.send_text.delay( + user_id=user.pk, + subject=subject, + text=text, + ) + + +@reply +async def send_photo(update: MessageUpdate, user: User) -> None: + file = await update.message.photo[-1].get_file() + photo = await download(file) + subject = "Photo note to self" + text = " " + + if update.message.caption is not None: + text = update.message.caption.strip() + if text: + subject = f"Photo: {get_subject(text)}" + + await update.message.reply_text(text=render("photo_is_sent")) + + tasks.send_file.delay( + user_id=user.pk, + file=photo, + filename=Path(file.file_path).name, # type: ignore[arg-type] + subject=subject, + text=text, + ) + + +@reply +async def prompt_for_setting_email(update: TextMessageUpdate) -> None: + await update.message.reply_text(text=render("please_send_email")) + + +@reply +async def send_confirmation(update: TextMessageUpdate, user: User) -> None: + email = update.message.text.strip() + + if User.select().where(User.email == email): + await update.message.reply_text(text=render("email_is_occupied")) + return + + user.email = email + user.save() + + tasks.send_confirmation_mail.delay(user.pk) + + await update.message.reply_text( + text=render("confirmation_message_is_sent", user=user), + ) + + +@reply +async def prompt_for_confirm(update: TextMessageUpdate) -> None: + reply_markup = ReplyKeyboardMarkup([["Change email"]]) + await update.message.reply_text(render("waiting_for_confirmation"), reply_markup=reply_markup) + + +class ConfirmedUserFilter(filters.MessageFilter): + def filter(self, message: HumanMessage) -> bool: + user = get_user_instance(message.from_user, message.chat_id) + + return user.is_confirmed + + +class UserWithoutEmailFilter(filters.MessageFilter): + def filter(self, message: HumanMessage) -> bool: + user = get_user_instance(message.from_user, message.chat_id) + + return user.email is None + + +class NonConfirmedUserFilter(filters.MessageFilter): + def filter(self, message: HumanMessage) -> bool: + user = get_user_instance(message.from_user, message.chat_id) + + return user.email is not None and user.is_confirmed is False + + +def bot_app() -> Application: + bot_token = os.getenv("BOT_TOKEN") + if bot_token is None: + raise RuntimeError("Please set BOT_TOKEN") # NOQA: TRY003 + + application = ApplicationBuilder().token(bot_token).build() + + application.add_handler(CommandHandler("start", start)) + application.add_handler(CommandHandler("reset", reset_email)) + application.add_handler( + MessageHandler(UserWithoutEmailFilter() & filters.TEXT & filters.Regex("@"), send_confirmation) + ) # looks like email, so send confirmation to it + application.add_handler( + MessageHandler( + NonConfirmedUserFilter() & filters.TEXT & filters.Regex("Change email"), + reset_email, + ) + ) # change email + application.add_handler( + MessageHandler( + NonConfirmedUserFilter() & filters.TEXT & filters.Regex(r"\w{8}\-\w{4}\-\w{4}\-\w{4}\-\w{12}"), + confirm_email, + ) + ) # confirm email + application.add_handler(MessageHandler(UserWithoutEmailFilter(), prompt_for_setting_email)) + application.add_handler(MessageHandler(NonConfirmedUserFilter(), prompt_for_confirm)) + application.add_handler(MessageHandler(ConfirmedUserFilter() & filters.TEXT, send_text_message)) + application.add_handler(MessageHandler(ConfirmedUserFilter() & filters.PHOTO, send_photo)) + + return application + + +def flask_app_from_bot(bot_app: Application) -> uvicorn.Server: + flask_app = Flask("bot_webhook") + secret = os.getenv("INCOMING_WEBHOOK_SECRET") + + @flask_app.post(f"/telegram-webhook-{secret}") + async def telegram() -> Response: + """Telegram updates""" + await bot_app.update_queue.put(Update.de_json(data=request.json, bot=bot_app.bot)) + return Response(status=200) + + @flask_app.get("/healthcheck") + def healthcheck() -> Response: + with kombu.Connection(os.getenv("CELERY_BROKER_URL")) as connection: + connection.connect() + return Response("ok") + + return uvicorn.Server( + config=uvicorn.Config( + app=WsgiToAsgi(flask_app), # type: ignore[no-untyped-call] + port=int(os.getenv("PORT", default=8000)), + host="0.0.0.0", + ) + ) + + +async def prod(bot_app: Application) -> None: + enable_logging() + init_sentry() + flask = flask_app_from_bot(bot_app) + url = os.getenv("INCOMING_WEBHOOK_URL") + secret = os.getenv("INCOMING_WEBHOOK_SECRET") + async with bot: + await bot_app.bot.set_webhook(url=f"{url}/telegram-webhook-{secret}", allowed_updates=Update.ALL_TYPES) + await bot.start() + await flask.serve() + await bot.stop() + + +def dev(bot: Application) -> None: + enable_logging() + bot.run_polling() + + +if __name__ == "__main__": + create_tables() + bot = bot_app() + + if os.getenv("BOT_ENV", default="dev") == "production": + asyncio.run(prod(bot)) + else: + dev(bot) diff --git a/src/celery.py b/src/celery.py index 24a61b8..84df238 100644 --- a/src/celery.py +++ b/src/celery.py @@ -1,41 +1,41 @@ -import sentry_sdk +import os +from io import BytesIO + from celery import Celery -from envparse import env -from sentry_sdk.integrations.celery import CeleryIntegration +from dotenv import load_dotenv -from .helpers import get_subject +from .helpers import init_sentry from .mail import send_mail from .models import User -from .recognize import recognize from .tpl import get_template -env.read_envfile() +load_dotenv() -celery = Celery('app') +celery = Celery("app") celery.conf.update( - broker_url=env('CELERY_BROKER_URL'), - task_always_eager=env('CELERY_ALWAYS_EAGER', cast=bool, default=False), - task_serializer='pickle', # we transfer binary data like photos or voice messages, - accept_content=['pickle'], + broker_url=os.getenv("CELERY_BROKER_URL"), + broker_connection_retry_on_startup=True, + task_always_eager=os.getenv("CELERY_ALWAYS_EAGER", default=False), + task_serializer="pickle", # we transfer binary data like photos or voice messages, + accept_content=["pickle"], ) -if env('SENTRY_DSN', default=None) is not None: - sentry_sdk.init(env('SENTRY_DSN'), integrations=[CeleryIntegration()]) +init_sentry() @celery.task -def send_confirmation_mail(user_id): +def send_confirmation_mail(user_id: int) -> None: user = User.get(User.pk == user_id) send_mail( to=user.email, - subject='[Selfmailbot] Confirm your email', - text=get_template('email/confirmation.txt').render(user=user), + subject="[Selfmailbot] Confirm your email", + text=get_template("email/confirmation.txt").render(user=user), ) @celery.task -def send_text(user_id, subject, text): +def send_text(user_id: int, subject: str, text: str) -> None: user = User.get(User.pk == user_id) send_mail( @@ -46,12 +46,9 @@ def send_text(user_id, subject, text): @celery.task -def send_file(user_id, file, filename, subject, text=''): +def send_file(user_id: int, file: BytesIO, filename: str, subject: str, text: str = " ") -> None: user = User.get(User.pk == user_id) - if not text: - text = ' ' - send_mail( to=user.email, text=text, @@ -59,21 +56,3 @@ def send_file(user_id, file, filename, subject, text=''): attachment=file, attachment_name=filename, ) - - -@celery.task -def send_recognized_voice(user_id, file, duration): - if duration <= 60: - recognized_text = recognize(file.read()) - subject = 'Voice: {}'.format(get_subject(recognized_text)) if recognized_text else 'Voice note to self' - else: - recognized_text = '' - subject = 'Voice note to self' - - send_file( - user_id=user_id, - file=file, - filename='voice.oga', - subject=subject, - text=recognized_text, - ) diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..57edb9e --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,6 @@ +class AppException(Exception): + ... + + +class AnonymousMessage(AppException): + """Message without a user""" diff --git a/src/framework.py b/src/framework.py new file mode 100644 index 0000000..d022710 --- /dev/null +++ b/src/framework.py @@ -0,0 +1,46 @@ +from functools import wraps +from inspect import signature +from typing import Any, Callable, Coroutine + +from telegram import Update +from telegram.ext import CallbackContext, filters + +from .models import User, get_user_instance +from .t import HumanMessage, MessageUpdate + + +def _get_user(update: MessageUpdate) -> User: + return get_user_instance( + update.message.from_user, # type: ignore[arg-type] + chat_id=update.message.chat_id, + ) + + +def reply(fn: Callable) -> Callable[[Update, CallbackContext], Coroutine]: + params = signature(fn).parameters + + @wraps(fn) + async def call(update: Update, context: CallbackContext) -> Any: + kwargs: dict[str, Any] = { + "update": update, + } + if "user" in params: + kwargs["user"] = _get_user(update) # type: ignore[arg-type] + + if "context" in params: + kwargs["context"] = context + + return await fn(**kwargs) + + return call + + +class Filter(filters.MessageFilter): + def filter(self, message: HumanMessage) -> bool: # type: ignore[override] + return False + + +__all__ = [ + "reply", + "Filter", +] diff --git a/src/helpers.py b/src/helpers.py index fba67e9..2dc0088 100644 --- a/src/helpers.py +++ b/src/helpers.py @@ -1,54 +1,63 @@ +import logging +import os import re import uuid from io import BytesIO -from os import path +from pathlib import Path +import sentry_sdk import telegram +from sentry_sdk.integrations.asyncio import AsyncioIntegration +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.flask import FlaskIntegration -from .models import with_user -from .tpl import get_template +def enable_logging() -> None: + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) -def reply(fn): - """Add a with_user decorator and a render function with additional ctx""" - def _call(*args, user, **kwargs): - def render(tpl: str, **kwargs): - template = get_template('messages/' + tpl + '.txt') - return template.render(user=user, **kwargs) +def init_sentry() -> None: + sentry_dsn = os.getenv("SENTRY_DSN", None) - return fn(*args, **kwargs, user=user, render=render) + if sentry_dsn: + sentry_sdk.init( + sentry_dsn, + integrations=[ + AsyncioIntegration(), + CeleryIntegration(), + FlaskIntegration(), + ], + ) - return with_user(_call) - -def capfirst(x): +def capfirst(x: str) -> str: """Capitalize the first letter of a string. Kindly borrowed from Django""" return x and str(x)[0].upper() + str(x)[1:] -def get_subject(text): +def get_subject(text: str) -> str: """Generate subject based on message text""" - words = [word.lower() for word in re.split(r'\s+', text)] + words = [word.lower() for word in re.split(r"\s+", text)] words[0] = capfirst(words[0]) if len(words) > 1: if len(words) in [2, 3]: - return ' '.join(words[:3]) + return " ".join(words[:3]) - return ' '.join(words[:3]) + '...' + return " ".join(words[:3]) + "..." if len(words[0]) < 32: return words[0][:32] - return words[0][:32] + '...' # first 32 characters + return words[0][:32] + "..." # first 32 characters -def download(file: telegram.File) -> BytesIO: +async def download(file: telegram.File) -> BytesIO: attachment = BytesIO() - attachment.name = str(uuid.uuid4()) + '.' + path.splitext(file.file_path)[1] - - file.download(out=attachment) - attachment.seek(0, 0) + attachment.name = str(uuid.uuid4()) + "." + Path(file.file_path).suffix # type: ignore[arg-type] - return attachment + downloaded = await file.download_as_bytearray() + return BytesIO(downloaded) diff --git a/src/mail.py b/src/mail.py index f888906..acf7c0b 100644 --- a/src/mail.py +++ b/src/mail.py @@ -1,18 +1,28 @@ +import os +from io import BytesIO + import pystmark -from envparse import env +from dotenv import load_dotenv +from .models import User from .tpl import get_template -env.read_envfile() +load_dotenv() class MailException(Exception): pass -def send_mail(to, subject, text, attachment=None, attachment_name=''): +def send_mail( + to: str, + subject: str, + text: str, + attachment: BytesIO | None = None, + attachment_name: str = "", +) -> None: message = pystmark.Message( - sender=env('MAIL_FROM'), + sender=os.getenv("MAIL_FROM"), to=to, subject=subject, text=text, @@ -21,15 +31,13 @@ def send_mail(to, subject, text, attachment=None, attachment_name=''): if attachment is not None: message.attach_binary(attachment.read(), attachment_name) - result = pystmark.send(message, api_key=env('POSTMARK_API_KEY')) + result = pystmark.send(message, api_key=os.getenv("POSTMARK_API_KEY")) result.raise_for_status() - return result - -def send_confirmation_mail(user): - return send_mail( +def send_confirmation_mail(user: User) -> None: + send_mail( to=user.email, - subject='[Selfmailbot] Please confirm your email', - text=get_template('email/confirmation.txt').render(user=user), + subject="[Selfmailbot] Please confirm your email", + text=get_template("email/confirmation.txt").render(user=user), ) diff --git a/src/models.py b/src/models.py index 4d0469c..4596f99 100644 --- a/src/models.py +++ b/src/models.py @@ -1,18 +1,20 @@ +import contextlib +import os import uuid import peewee as pw import telegram -from envparse import env +from dotenv import load_dotenv from playhouse.db_url import connect -env.read_envfile() +load_dotenv() -db = connect(env('DATABASE_URL', cast=str, default='sqlite:///db.sqlite')) +db = connect(os.getenv("DATABASE_URL")) class User(pw.Model): pk = pw.BigIntegerField(index=True, unique=True) - created = pw.DateTimeField(constraints=[pw.SQL('DEFAULT CURRENT_TIMESTAMP')]) + created = pw.DateTimeField(constraints=[pw.SQL("DEFAULT CURRENT_TIMESTAMP")]) full_name = pw.CharField() username = pw.CharField(null=True) email = pw.CharField(index=True, null=True) @@ -26,35 +28,23 @@ class Meta: def get_user_instance(user: telegram.User, chat_id: int) -> User: - instance, created = User.get_or_create( + """DB user instance based on telegram user data""" + return User.get_or_create( pk=user.id, - defaults=dict( - pk=user.id, - full_name=user.full_name, - username=user.username, - confirmation=str(uuid.uuid4()), - chat_id=chat_id, - ), - ) - return instance - - -def get_user_by_confirmation_link(link) -> User: - try: + defaults={ + "pk": user.id, + "full_name": user.full_name, + "username": user.username, + "confirmation": str(uuid.uuid4()), + "chat_id": chat_id, + }, + )[0] + + +def get_user_by_confirmation_link(link: str) -> User | None: + with contextlib.suppress(User.DoesNotExist): return User.get(User.confirmation == link) - except User.DoesNotExist: - pass -def with_user(fn): - """Decorator to add kwarg with registered user instance to the telegram.ext handler""" - def call(bot, update, *args, **kwargs): - kwargs['user'] = get_user_instance(update.message.from_user, chat_id=update.message.chat_id) - - return fn(bot, update, *args, **kwargs) - - return call - - -def create_tables(): +def create_tables() -> None: db.create_tables([User]) diff --git a/src/recognize.py b/src/recognize.py deleted file mode 100644 index afe5158..0000000 --- a/src/recognize.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Iterable - -from envparse import env -from google.cloud import speech -from google.cloud.speech import enums, types - -env.read_envfile() - - -def do_recognition(stream: bytes) -> Iterable: - client = speech.SpeechClient() - - audio = types.RecognitionAudio(content=stream) - config = types.RecognitionConfig( - encoding=enums.RecognitionConfig.AudioEncoding.OGG_OPUS, - sample_rate_hertz=16000, - language_code='ru-RU', - ) - recognition = client.long_running_recognize(config, audio).result(timeout=90) - return [result.alternatives[0].transcript for result in recognition.results] - - -def recognize(stream: bytes) -> str: - recognized = do_recognition(stream) - return ' '.join(recognized) diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index bc2aaff..0000000 --- a/src/requirements.txt +++ /dev/null @@ -1,90 +0,0 @@ -amqp==2.5.2 -appnope==0.1.0 -atomicwrites==1.4.0 -attrs==19.3.0 -Babel==2.8.0 -backcall==0.1.0 -billiard==3.6.3.0 -blinker==1.4 -cachetools==4.1.0 -celery==4.4.2 -certifi==2018.4.16 -cffi==1.14.0 -chardet==3.0.4 -click==7.1.2 -cryptography==2.9.2 -decorator==4.4.2 -entrypoints==0.3 -envparse==0.2.0 -Faker==4.1.0 -flake8==3.8.1 -flake8-bugbear==20.1.4 -flake8-commas==2.0.0 -flake8-isort==3.0.0 -flake8-polyfill==1.0.2 -flake8-print==3.1.4 -Flask==1.1.1 -Flask-Env==2.0.0 -flower==0.9.4 -future==0.18.2 -google-api-core==1.17.0 -google-auth==1.14.1 -google-cloud-speech==1.3.2 -googleapis-common-protos==1.6.0 -grpcio==1.29.0 -humanize==2.4.0 -idna==2.9 -importlib-metadata==1.7.0 -ipython==7.14.0 -ipython-genutils==0.2.0 -isort==4.3.21 -itsdangerous==0.24 -jedi==0.17.0 -Jinja2==2.11.2 -kombu==4.6.8 -MarkupSafe==1.1.1 -mccabe==0.6.1 -mocker==1.1.1 -more-itertools==8.3.0 -packaging==20.3 -parso==0.7.0 -peewee==3.13.3 -pep8-naming==0.10.0 -pexpect==4.8.0 -pickleshare==0.7.5 -pluggy==0.13.1 -prompt-toolkit==3.0.5 -protobuf==3.11.3 -ptyprocess==0.6.0 -py==1.8.1 -pyasn1==0.4.8 -pyasn1-modules==0.2.8 -pycodestyle==2.6.0 -pycparser==2.20 -pyflakes==2.2.0 -Pygments==2.6.1 -pyparsing==2.4.7 -pystmark==0.4.8 -pytest==5.4.3 -pytest-env==0.6.2 -pytest-flask==1.0.0 -pytest-mock==3.1.0 -python-dateutil==2.8.1 -python-telegram-bot==12.7 -pytz==2020.1 -redis==3.5.0 -requests==2.23.0 -requests-mock==1.8.0 -rsa==4.0 -sentry-sdk==0.14.4 -six==1.14.0 -testfixtures==6.14.1 -text-unidecode==1.3 -toml==0.10.0 -tornado==6.0.4 -traitlets==4.3.3 -urllib3==1.25.9 -vine==1.3.0 -wcwidth==0.2.4 -Werkzeug==1.0.1 -zipp==3.1.0 diff --git a/src/t.py b/src/t.py new file mode 100644 index 0000000..f287b10 --- /dev/null +++ b/src/t.py @@ -0,0 +1,26 @@ +from telegram import Message, PhotoSize, Update, User + + +class HumanMessage(Message): + from_user: User + + +class MessageUpdate(Update): + message: Message + + +class TextMessage(HumanMessage): + text: str + + +class TextMessageUpdate(MessageUpdate): + message: TextMessage + + +class PhotoMessage(HumanMessage): + photo: tuple[PhotoSize] + text: str | None + + +class FileMessageUpdate(MessageUpdate): + message: PhotoMessage diff --git a/src/templates/messages/confirmation_message_is_sent.txt b/src/templates/messages/confirmation_message_is_sent.txt index 728fd76..ea8cd9b 100644 --- a/src/templates/messages/confirmation_message_is_sent.txt +++ b/src/templates/messages/confirmation_message_is_sent.txt @@ -1 +1 @@ -Confirmation message is sent to {{ user.email }}. Click the link in your inbox or copy key from confirmation email and send me, and we are all set up! +Confirmation message is sent to {{ user.email }}. Click the link in your inbox or copy the key from confirmation email and send me, and we are all set up! diff --git a/src/templates/messages/email_is_confirmed.txt b/src/templates/messages/email_is_confirmed.txt index 65a5e6b..3687d6c 100644 --- a/src/templates/messages/email_is_confirmed.txt +++ b/src/templates/messages/email_is_confirmed.txt @@ -1,3 +1,3 @@ Ok, email is confirmed. -Now send me message, photo or a voicemessage, and i will deliver it right to your inbox. +Now send me message or a photo, and i will deliver it right to your inbox. diff --git a/src/templates/messages/hello_message.txt b/src/templates/messages/hello_message.txt index 24ffa46..f326bb8 100644 --- a/src/templates/messages/hello_message.txt +++ b/src/templates/messages/hello_message.txt @@ -1,3 +1,3 @@ Hey! -I forward all messages right to your email inbox. To start, send me your email, like me@selfmailbot.com. +I forward all messages right to your email inbox. To start, send me your email, like me@selfmailbot.co. diff --git a/src/templates/messages/please_confirm.txt b/src/templates/messages/please_confirm.txt deleted file mode 100644 index 7d309ac..0000000 --- a/src/templates/messages/please_confirm.txt +++ /dev/null @@ -1 +0,0 @@ -Confirmation link is sent to your email {{ user.email }}. Please click it, to start using the bot. diff --git a/src/templates/messages/waiting_for_confirmation.txt b/src/templates/messages/waiting_for_confirmation.txt index 52a485e..489d8a4 100644 --- a/src/templates/messages/waiting_for_confirmation.txt +++ b/src/templates/messages/waiting_for_confirmation.txt @@ -1,3 +1 @@ I am waiting for you to click the confirmation link in your email. - -Having troubles? Resend the message, enter another email, or contact me at me@selfmailbot.co. diff --git a/src/tpl.py b/src/tpl.py index 6751c0e..94c1a1e 100644 --- a/src/tpl.py +++ b/src/tpl.py @@ -1,9 +1,11 @@ -from os.path import dirname, join +from pathlib import Path from jinja2 import Environment, FileSystemLoader, Template +from .models import User + env = Environment( - loader=FileSystemLoader(join(dirname(__file__), 'templates')), + loader=FileSystemLoader(Path(__file__).parent / "templates"), ) @@ -11,6 +13,12 @@ def get_template(template_name: str) -> Template: return env.get_template(template_name) +def render(tpl: str, **kwargs: str | User) -> str: + template = get_template("messages/" + tpl + ".txt") + return template.render(**kwargs) + + __all__ = [ - get_template, + "get_template", + "render", ] diff --git a/src/web.py b/src/web.py index b153269..b81eec0 100644 --- a/src/web.py +++ b/src/web.py @@ -1,45 +1,24 @@ -import sentry_sdk -from flask import Flask, g, render_template -from flask_env import MetaFlaskEnv -from sentry_sdk.integrations.flask import FlaskIntegration -from telegram import Bot +from dotenv import load_dotenv +from flask import Flask, render_template +from .helpers import init_sentry from .models import get_user_by_confirmation_link +load_dotenv() -class Configuration(metaclass=MetaFlaskEnv): - DEBUG = False - PORT = 5000 - BOT_TOKEN = '' - SENTRY_DSN = None +app = Flask("confirmation_webapp") +init_sentry() -app = Flask(__name__) -app.config.from_object(Configuration) -if Configuration.SENTRY_DSN is not None: - sentry_sdk.init(Configuration.SENTRY_DSN, integrations=[FlaskIntegration()]) - - -def get_bot(): - return Bot(app.config['BOT_TOKEN']) - - -@app.before_request -def init_bot(): - g.bot = get_bot() - - -@app.route('/confirm//') -def confirm(key): +@app.route("/confirm//") +def confirm(key: str) -> str: user = get_user_by_confirmation_link(key) if user is None: - return render_template('html/confirmation_failure.html') + return render_template("html/confirmation_failure.html") user.is_confirmed = True user.save() - g.bot.send_message(chat_id=user.chat_id, text=render_template('messages/email_is_confirmed.txt')) - - return render_template('html/confirmation_ok.html') + return render_template("html/confirmation_ok.html") diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_get_subject.py b/tests/test_get_subject.py deleted file mode 100644 index e211bba..0000000 --- a/tests/test_get_subject.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest - -from src.helpers import get_subject - - -@pytest.mark.parametrize('input, expected', [ - ['a b c d', 'A b c...'], - ['a b c d', 'A b c...'], - ['a b', 'A b'], - ['a b c', 'A b c'], - ['1 2 3', '1 2 3'], # ensure capfirst does not break on digits - ['A b c', 'A b c'], - [''.join('a' for _ in range(35)), 'A' + ''.join('a' for _ in range(31)) + '...'], - [''.join('a' for _ in range(27)), 'A' + ''.join('a' for _ in range(26))], -]) -def test_get_subject(input, expected): - assert get_subject(input) == expected diff --git a/tests/test_recognize.py b/tests/test_recognize.py deleted file mode 100644 index cf95629..0000000 --- a/tests/test_recognize.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from src import recognize - - -@pytest.fixture -def recognition_result(mocker): - return mocker.patch('src.recognize.do_recognition') - - -def test_success_recognition(recognition_result): - recognition_result.return_value = ['Сходить в', 'магазин'] - - assert recognize.recognize(b'testshit') == 'Сходить в магазин' - - -def test_no_recognition(recognition_result): - recognition_result.return_value = [] - - assert recognize.recognize(b'testshit') == '' diff --git a/tests/test_resend.py b/tests/test_resend.py deleted file mode 100644 index ae4e9d1..0000000 --- a/tests/test_resend.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest - - -@pytest.fixture(autouse=True) -def send_mail(mocker): - return mocker.patch('src.app.tasks.send_confirmation_mail.delay') - - -def test(bot_app, update, models, send_mail): - user = models.get_user_instance(update.message.from_user, 100500) - - bot_app.call('resend', update) - - send_mail.assert_called_once_with(user.pk) diff --git a/tests/test_reset.py b/tests/test_reset.py deleted file mode 100644 index 48534a1..0000000 --- a/tests/test_reset.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - - -@pytest.fixture(autouse=True) -def send_mail(mocker): - return mocker.patch('src.app.tasks.send_confirmation_mail') - - -def test(bot_app, update, models, send_mail): - user = models.get_user_instance(update.message.from_user, 100500) - user.email = 'test@test.org' - user.save() - - bot_app.call('reset_email', update) - user = models.User.get(models.User.pk == user.pk) - - assert user.email is None - assert user.is_confirmed is False diff --git a/tests/test_send_confirmation.py b/tests/test_send_confirmation.py deleted file mode 100644 index de9faa8..0000000 --- a/tests/test_send_confirmation.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest - - -@pytest.fixture(autouse=True) -def send_mail(mocker): - return mocker.patch('src.app.tasks.send_confirmation_mail.delay') - - -def test_occupied_email(bot_app, update, db_user): - db_user(email='occu@pie.d') - update.message.text = 'occu@pie.d' - - bot_app.call('send_confirmation', update) - - msg = update.message.reply_text.call_args[1]['text'] - assert 'occupied' in msg - - -def test_email_is_not_sent_to_occupied_one(bot_app, update, db_user, send_mail): - db_user(email='occu@pie.d') - update.message.text = 'occu@pie.d' - - bot_app.call('send_confirmation', update) - - assert not send_mail.called - - -def test_ok(bot_app, update, bot, send_mail): - update.message.text = 'ok@e.mail' - - bot_app.call('send_confirmation', update) - - assert send_mail.called - - -def test_email_is_sent_to_correct_user(bot_app, update, send_mail, models): - user = models.get_user_instance(update.message.from_user, 100500) - update.message.text = 'ok@e.mail' - - bot_app.call('send_confirmation', update) - - send_mail.assert_called_once_with(user.pk) - - -def test_email_is_updated(bot_app, update, send_mail, models): - user = models.get_user_instance(update.message.from_user, 100500) - update.message.text = 'ok@e.mail' - - bot_app.call('send_confirmation', update) - user = models.User.get(models.User.pk == user.pk) - - assert user.email == 'ok@e.mail' diff --git a/tests/test_send_photo.py b/tests/test_send_photo.py deleted file mode 100644 index ecef6a4..0000000 --- a/tests/test_send_photo.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest - - -@pytest.fixture -def send_mail(mocker): - return mocker.patch('src.celery.send_mail') - - -@pytest.fixture -def update(update, tg_photo_size): - update.message.photo = [tg_photo_size()] - update.message.caption = None - return update - - -@pytest.fixture(autouse=True) -def user(update, models): - user = models.get_user_instance(update.message.from_user, 100500) - user.email = 'mocked@test.org' - user.save() - - -def test_send_photo_with_caption(bot_app, update, models, send_mail, mocker, photo): - update.message.caption = 'Слоны идут на север' - bot_app.call('send_photo', update) - - attachment = send_mail.call_args[1]['attachment'] - attachment.seek(0, 0) - - assert attachment.read() == photo - - send_mail.assert_called_once_with( - to='mocked@test.org', - subject='Photo: Слоны идут на...', - text='Слоны идут на север', - attachment=attachment, - attachment_name='file.png', - ) - - -def test_send_photo_without_caption(bot_app, update, models, send_mail, mocker, photo): - bot_app.call('send_photo', update) - - attachment = send_mail.call_args[1]['attachment'] - attachment.seek(0, 0) - - assert attachment.read() == photo - - send_mail.assert_called_once_with( - to='mocked@test.org', - subject='Photo note to self', - text=' ', - attachment=attachment, - attachment_name='file.png', - ) diff --git a/tests/test_send_text_message.py b/tests/test_send_text_message.py deleted file mode 100644 index 41f9466..0000000 --- a/tests/test_send_text_message.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest - - -@pytest.fixture -def send_mail(mocker): - return mocker.patch('src.celery.send_mail') - - -@pytest.fixture -def update(update): - update.message.text = 'Слоны идут на север' - - return update - - -@pytest.fixture(autouse=True) -def user(update, models): - user = models.get_user_instance(update.message.from_user, 100500) - user.email = 'mocked@test.org' - user.save() - - -def test(bot_app, update, models, send_mail, mocker): - bot_app.call('send_text_message', update) - - send_mail.assert_called_once_with( - to='mocked@test.org', - subject='Слоны идут на...', - text='Слоны идут на север', - ) diff --git a/tests/test_send_voice.py b/tests/test_send_voice.py deleted file mode 100644 index 65cdd1e..0000000 --- a/tests/test_send_voice.py +++ /dev/null @@ -1,101 +0,0 @@ -import base64 - -import pytest - -pytestmark = [ - pytest.mark.xfail(reason='Voice messages are switched off'), -] - - -@pytest.fixture -def recognition_result(mocker): - return mocker.patch('src.recognize.do_recognition') - - -@pytest.fixture -def send_mail(mocker): - return mocker.patch('src.celery.send_mail') - - -@pytest.fixture(autouse=True) -def user(update, models): - user = models.get_user_instance(update.message.from_user, 100500) - user.email = 'mocked@test.org' - user.save() - - -@pytest.fixture -def voice(): - """1 sec ogg file without speech in base64""" - ogg_b64 = 'T2dnUwACAAAAAAAAAACfq5k2AAAAANaJ8cABE09wdXNIZWFkAQE4AYA+AAAAAABPZ2dTAAAAAAAAAAAAAJ+rmTYBAAAAUkVZJwP///5PcHVzVGFncxUAAABsaWJvcHVzIHVua25vd24tZml4ZWQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE9nZ1MAAEALAAAAAAAAn6uZNgIAAAAE4h3mATFYIvkVcZ0w2L4puaSqZ5IUV5Fb+mxngFCBVU8r474jrWzTIrSTXJ8xPZeM260bHacIT2dnUwAAgBYAAAAAAACfq5k2AwAAAJgHiI0BVFiAJy9f6fzFhHyo7rlimrfJfRC8o1iBg3/VFKfZLwv34gQuLKMO9c8cG8u0YPKtDHBwEsgDjunFwqAmA0K4HYwH9j9a8lCNxpwfIWT+d2brlth7uE9nZ1MAAMAhAAAAAAAAn6uZNgQAAAArSioEAVFYYOPnkvQ6RB27Ab+IK1zRBzwHuSwisN81bSRVXdukX+AoO76hciKy8sebVeSwdH+csosW8ha5LxuIjaN7+eCtjyIN3fArfSrpz+tNhUQRrpBPZ2dTAAAALQAAAAAAAJ+rmTYFAAAAM8+ONgFlWOIvrjAqsfnfKcOzRF33NQFmsxw64QYL163PYGEbJBhdbtJj4MUmHfxrTPaOyWqL/UOvjuTsOY9nGkbymqDzF7Ab6He68z5BoAE20186DZR0wx3XsJL84x9/ssA1fNWLPDl6/L1PZ2dTAABAOAAAAAAAAJ+rmTYGAAAA0bh0SAFiWMJJsLkCLkGjro4O/7XjixZoxhXM6HwAZXhSzQyADRuZ4nj+sxErcKhQ6m5L2WGCNTqmkIdhdLfof+OnjLlopuGF36GJMxYGceRJT76XDVRzyOqNtDv2Lo4O67Uh81Y4LIBPZ2dTAACAQwAAAAAAAJ+rmTYHAAAADlftFwFfWAvMh0xtIhhZjsyISiIqPG7xX02OY3wJ7ixt8iWRWgY4mFEExUeQU3RmzPs8yvAqj4mWljHTxaaBnJmcchjtT23/f0nRbqno0v8tlVGKNmuBFQ75ogyF5EPiqgZahOBPZ2dTAADATgAAAAAAAJ+rmTYIAAAA8RXhoAFXWAvMkLFSh48QuvsH3a3Njyuvt0VbPw/Tb/QCpZ3fqM6jr2WES797X00v1ISR7YhJIbBev8+tL2QIufPkhs/4j+1zzVo7/AEydMDBOn4mr4/sczGS3jPkT2dnUwAAAFoAAAAAAACfq5k2CQAAABidIeYBWlgLzIdiI8c2o+ZVhUgfueUOU445pm0niidOC7QG7zlf5SuzQWPnqrDsqPcS2cHdWXVK1rjzrJ+UvmvXuV+HKThzUbGbQdJcPbBajfgWTJpEJbyfiAKfjps07k9nZ1MAAEBlAAAAAAAAn6uZNgoAAADt4JwuAWJYC8yOSLpKuLVyp7xdNoXEoIRKbByolJogPORbD1gvHn67fKYHwCwg0+u6l2mo1ihWv6S0KeHVwh5jO/Og5cmDSMJOir9275GQQ4PiHfROOXSqLcy8kkdFf97V0fSyiqI9QE9nZ1MAAIBwAAAAAAAAn6uZNgsAAACp7EqoAVlYC8x2+k+QHdFMIN+WhFOH77/dBT8srgaelmCDfGRFs4D5D7sdLyvvnlxceigKyuYffEyFCMsmSQPRJ8lqUt7nDFUhUonyEytorsLKHHR4XEBg0xGNvY34c09nZ1MAAMB7AAAAAAAAn6uZNgwAAAC/9HilAVxYC8x3EFnGHX95S2vs2EZLAdSI69Ch1E7KqfXgammnRuKGE9dKAVAOpwKKqVoUNujXsV+eIPoo2x7Dy7Iuqv6XDgFWjjFh77/2b2LhkFY2JcdVY+446ZLAc5x9wE9nZ1MAAACHAAAAAAAAn6uZNg0AAADq8gzEAVpYC8ycGhU0DLhf8wzptsJ46Noe5JgeI8nH+sZ9ilP73YoNsj5a8DOSy3U5Z7wLhJQvFCPB+WffoazcPpkiomq3EtzJmKJe7nlqCRyLr6cuhpl38IrBfh+C6cBPZ2dTAABAkgAAAAAAAJ+rmTYOAAAAw6eLHgFcWAvOepfK2hbwXZIdBza0NIqAHvjXX+KOWQ7uSihwn0wYb14d4GiZaDUHhcVrFXZF+OWs1U/cyWIvAgJVMOyRAoCKMuHmeZsPhnG6xGUyQ1f2v297vS1rZvqVzoBPZ2dTAACAnQAAAAAAAJ+rmTYPAAAAJD8FCQFbWAvP9cyNfzoYRDba7i9AYe9nokZELlQT+PloGr85sVEIssO/YvNfDbisvxEKNSuyHiqecZ/90KBK2CXviNjgS7kD4/28fIItGIXF9A5MJXpPsETzMgbXanwcwE9nZ1MAAMCoAAAAAAAAn6uZNhAAAACBOjjwAVhYC8yOVOI1E9QZAtft5oLXZzPZLjebxW3BQesyFQyV/ga8eWKtb2WY2atpqeZOjw89XEMzk544lEjOwuYWnu+BE3SR/a0iBqpTsabUutN5cVtfJYdZ9PDUT2dnUwAAALQAAAAAAACfq5k2EQAAAFIjJf8BXVgLzJwidHr9WtZ9lWO+EdUpIYH4ZcCQcx8w9g9jSth1UvV4KsYkp/wRXVHCNtt63AENriBPzAX64Z3BeUNc8cCoSemtHydYfbNJc1Dlaa+l1R3E0Y53bwXrzcUVYE9nZ1MAAEC/AAAAAAAAn6uZNhIAAAAObXsfAVlYC8x2wGVSAxGqjGHTF2tfdsnc7AWgQ+i8tK/cXWfrKRJFAfWF9m0m/U3DLR+f2S59JzyM0YHrgCUY8euk05nN7XSX1pARcC0DKAppIn1nf+3pe9F29B8UgE9nZ1MAAIDKAAAAAAAAn6uZNhMAAABqO8cFAV9YC8gOY2e8dCbCg/QFCbkq/ICoKQX/DFkLXPyNNGhz6DoFg3wRSKDW2+03AKjunvCNlob9oh1O+1jVAmP+CXTsKBB1Ii3Z77t98/et57NWQ7U/llbGpsMVCOAgdpcdgE9nZ1MAAMDVAAAAAAAAn6uZNhQAAACgFs1oAVtYC8yQsI2fbcsyPyhRMJj99/UMf0D5BlW6Cixdx0BFJ4tk9IR8GulsnV+KAr+RZicoWeYPHAKaiaqmjzY/pRCgP0sy4gPLXdDFN+DZjcmnIRRCpcMkm1sqRDEQT2dnUwAAAOEAAAAAAACfq5k2FQAAAJWNjegBWFgLzHcQILiZ7UAuaBwijON/pszPxf3Hinot/XDBCQf53kowO3CoO3sx5p0QEC80kxZEg87SSxDcwwpE147dSBCCnf6WO4WACP4BZ9RiK8Qn5mNZuV2yWUBPZ2dTAABA7AAAAAAAAJ+rmTYWAAAAC6C2XAFiWAvMbXyXwG/dWjYxCaEGGIOAvo6oldhIi+zKQ39bm9a5hdOB/fgCY59U9gwpd1aH17JqWivRm/Yquj4K2MSeme98L5jf5VBQ9th3Mn0hM2V4JRypyLzYoUIQnmkNLgWOxhJPZ2dTAACA9wAAAAAAAJ+rmTYXAAAAnDavowFiWGvMjmpudD+DvqVeORbl+aylJ751Sa/6dF9eDXiqzRP9bBmzEyYi3HcmiwkuIwiFbs+ZRnx8nkqg7xLkMxSUp9uw3YsaceGyNfqCNvu3V748QgS2h1LJ3f3jxUuV37cgriBPZ2dTAADAAgEAAAAAAJ+rmTYYAAAAmRWs9wFYWAxJnrk892FAan/x8/l3cYyh5uRAuOmKgb2rpskJ2tDasgmiCJkuO1dZTTBh8gZ5Wug9jgAGjeEaEPLlMjtMAEmcevgQih/gfwVLRXSjLiM9dD8k4dukoE9nZ1MABPgJAQAAAAAAn6uZNhkAAADIV65cAUNYC8ycITEYQt3Bxy2qOTH9Gl/eH3HvarcrT85NuQwXVJ3JyPfaHuzAi7QFjEehNQF7WdjVQQLMfIT7ROKgR1vyt7vy' - return base64.b64decode(ogg_b64) - - -@pytest.fixture(autouse=True) -def update(update, voice): - """Add Voice instance to regular update.message""" - class FakeVoice: - - @staticmethod - def get_file(): - class File: - file_path = '/tmp/path/to/file.ogg' - - @staticmethod - def download(custom_path=None, out=None, timeout=None): - out.write(voice) - - file = File() - return file - - update.message.voice = FakeVoice() - return update - - -def get_attachment(cmd): - attachment = cmd.call_args[1]['attachment'] - attachment.seek(0, 0) - - return attachment.read() - - -@pytest.mark.parametrize('duration', [50, 70]) -def test_attachment(bot_app, update, send_mail, voice, duration, recognition_result): - update.message.voice.duration = duration - recognition_result.return_value = [] - bot_app.call('send_voice', update) - - assert get_attachment(send_mail) == voice - - -@pytest.mark.parametrize( - 'duration, recognized, subject, text', - [ - pytest.param( - 30, - ['большой', 'зеленый камнеед', 'сидит', 'в пруду'], - 'Voice: Большой зеленый камнеед...', - 'большой зеленый камнеед сидит в пруду', - id='short_recognized', - ), - pytest.param( - 30, [], 'Voice note to self', ' ', id='short_unrecognized', - ), - pytest.param( - 90, [], 'Voice note to self', ' ', id='long', - ), - ], -) -def test_send_voice(bot_app, update, send_mail, recognition_result, duration, recognized, subject, text): - update.message.voice.duration = duration - recognition_result.return_value = recognized - bot_app.call('send_voice', update) - attachment = send_mail.call_args[1]['attachment'] - - send_mail.assert_called_once_with( - to='mocked@test.org', - subject=subject, - text=text, - attachment=attachment, - attachment_name='voice.oga', - ) diff --git a/tests/test_start.py b/tests/test_start.py deleted file mode 100644 index 26a645e..0000000 --- a/tests/test_start.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest - - -@pytest.fixture -def create_user_from_tg(models, tg_user): - def _create(**kwargs): - created = models.get_user_instance(tg_user, 100500) - - for key, value in kwargs.items(): - setattr(created, key, value) - - created.save() - return created - - return _create - - -def test(bot_app, update): - bot_app.call('start', update) - - assert update.message.reply_text.called - - -def test_user_creation(bot_app, update, models, tg_user): - bot_app.call('start', update) - - saved = models.User.get(pk=tg_user.id) - - assert saved.pk == tg_user.id - assert saved.full_name == f'{tg_user.first_name} {tg_user.last_name}' - assert saved.is_confirmed is False - assert saved.email is None - assert saved.chat_id == update.message.chat_id - - -def test_second_start_for_existing_user_does_not_update_name(bot_app, update, create_user_from_tg, models): - created = create_user_from_tg(full_name='Fixed and not updated') - - bot_app.call('start', update) - updated = models.User.get(pk=created.pk) - - assert updated.full_name == 'Fixed and not updated' - - -def test_second_start_for_confirmed_user_does_not_reset_the_confirmation_flag(bot_app, update, create_user_from_tg, models): - created = create_user_from_tg(is_confirmed=True) - - bot_app.call('start', update) - updated = models.User.get(pk=created.pk) - - assert updated.is_confirmed is True diff --git a/tests/test_tg_confirmation.py b/tests/test_tg_confirmation.py deleted file mode 100644 index c875312..0000000 --- a/tests/test_tg_confirmation.py +++ /dev/null @@ -1,43 +0,0 @@ -import uuid - -import pytest - - -@pytest.fixture(autouse=True) -def user(update, models): - user = models.get_user_instance(update.message.from_user, 100500) - return user - - -def test_user_is_notified(bot_app, update, user, bot): - update.message.text = user.confirmation - bot_app.call('confirm_email', update) - - assert update.message.reply_text.called - - kwargs = update.message.reply_text.call_args[1] - assert 'confirmed' in kwargs['text'] - - -def test_user_is_confirmed(bot_app, update, user, models): - update.message.text = user.confirmation - bot_app.call('confirm_email', update) - - user = models.User.get(pk=user.pk) - assert user.is_confirmed is True - - -def test_key_mismatch(bot_app, update, user): - update.message.text = str(uuid.uuid4()) - bot_app.call('confirm_email', update) - - msg = update.message.reply_text.call_args[1]['text'] - assert 'wrong' in msg - - -def test_user_is_not_confirmed(bot_app, update, user, models): - update.message.text = str(uuid.uuid4()) - bot_app.call('confirm_email', update) - - user = models.User.get(pk=user.pk) - assert user.is_confirmed is False diff --git a/tests/test_web_confirmation.py b/tests/test_web_confirmation.py deleted file mode 100644 index fafa155..0000000 --- a/tests/test_web_confirmation.py +++ /dev/null @@ -1,47 +0,0 @@ -import uuid - -import pytest - - -@pytest.fixture(autouse=True) -def user(db_user): - return db_user() - - -def test_confirmation_ok(client, user): - got = client.get(f'/confirm/{user.confirmation}/') - - assert 'confirmation ok' in str(got.data) - - -def test_user_is_notified(client, user, bot): - client.get(f'/confirm/{user.confirmation}/') - - assert bot.send_message.called - - kwargs = bot.send_message.call_args[1] - - assert kwargs['chat_id'] == user.chat_id - assert 'confirmed' in kwargs['text'] - - -def test_user_is_conrirmed(client, user, models): - client.get(f'/confirm/{user.confirmation}/') - - user = models.User.get(pk=user.pk) - - assert user.is_confirmed is True - - -def test_key_mismatch(client): - got = client.get(f'/confirm/{uuid.uuid4()}/') - - assert 'confirmation failure' in str(got.data) - - -def test_user_is_not_confirmed(client, user, models): - client.get(f'/confirm/{uuid.uuid4()}/') - - user = models.User.get(pk=user.pk) - - assert user.is_confirmed is False