├── tests ├── __init__.py ├── commands │ ├── test_age.py │ ├── test_add.py │ ├── test_joke.py │ └── test_topchannels.py ├── test_settings.py ├── test_db.py ├── test_slack.py ├── test_bot.py ├── conftest.py └── test_karma.py ├── runtime.txt ├── Procfile ├── poetry.toml ├── src └── karmabot │ ├── __main__.py │ ├── db │ ├── modelbase.py │ ├── __all_models.py │ ├── karma_user.py │ ├── karma_note.py │ ├── karma_transaction.py │ └── database.py │ ├── commands │ ├── age.py │ ├── zen.py │ ├── feed.py │ ├── tip.py │ ├── add.py │ ├── joke.py │ ├── score.py │ ├── template.py │ ├── update_username.py │ ├── doc.py │ ├── welcome.py │ ├── control.py │ ├── note.py │ └── topchannels.py │ ├── main.py │ ├── exceptions.py │ ├── __init__.py │ ├── slack.py │ ├── settings.py │ ├── karma.py │ └── bot.py ├── .flake8 ├── docker-compose.yml ├── .github └── workflows │ ├── safety.yml │ ├── release.yml │ ├── coverage.yml │ ├── tests.yml │ └── pre_release.yml ├── LICENSE ├── .pre-commit-config.yaml ├── .gitignore ├── pyproject.toml ├── noxfile.py ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.11.2 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: python src/karmabot/main.py 2 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /src/karmabot/__main__.py: -------------------------------------------------------------------------------- 1 | from karmabot.main import main 2 | 3 | if __name__ == "__main__": 4 | exit(main()) 5 | -------------------------------------------------------------------------------- /src/karmabot/db/modelbase.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | # Creates a base model you can derive model classes from 5 | class SqlAlchemyBase(DeclarativeBase): 6 | pass 7 | -------------------------------------------------------------------------------- /src/karmabot/db/__all_models.py: -------------------------------------------------------------------------------- 1 | # Add all models here. Ensures loading all models 2 | import karmabot.db.karma_note # noqa: F401 3 | import karmabot.db.karma_transaction # noqa: F401 4 | import karmabot.db.karma_user # noqa: F401 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.github,__pycache__,old,build,dist,.venv,.pytest_cache,.nox,.coverage,.karmabot 3 | max-line-length = 88 4 | max-complexity = 10 5 | extend-ignore = E501,S311 6 | application-import-names = karmabot,tests 7 | per-file-ignores = tests/*:S101 8 | -------------------------------------------------------------------------------- /tests/commands/test_age.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from freezegun import freeze_time 4 | 5 | from karmabot.commands.age import pybites_age 6 | 7 | 8 | @freeze_time(datetime(2017, 8, 23)) 9 | def test_pybites_age(): 10 | expected = "PyBites is 247 days old" 11 | assert pybites_age() == expected 12 | -------------------------------------------------------------------------------- /src/karmabot/commands/age.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | PYBITES_BORN = datetime(year=2016, month=12, day=19) 4 | 5 | 6 | def pybites_age(**kwargs) -> str: 7 | """Print PyBites age in days""" 8 | today = datetime.now() 9 | days_old = (today - PYBITES_BORN).days 10 | return f"PyBites is {days_old} days old" 11 | -------------------------------------------------------------------------------- /tests/commands/test_add.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from karmabot.commands.add import MSG, add_command 4 | 5 | 6 | @pytest.mark.parametrize("test_id", [("ABC123"), ("XYZ789")]) 7 | def test_add_command(test_id): 8 | actual = add_command(user_id=test_id) 9 | expected = MSG.format(username=f"<@{test_id}>") 10 | assert actual == expected 11 | -------------------------------------------------------------------------------- /src/karmabot/commands/zen.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import io 3 | 4 | 5 | def import_this(**kwargs): 6 | """Print the Zen of Python""" 7 | # https://stackoverflow.com/a/23794519 8 | zen = io.StringIO() 9 | with contextlib.redirect_stdout(zen): 10 | import this # noqa: F401 11 | 12 | text = f"```{zen.getvalue()}```" 13 | return text 14 | -------------------------------------------------------------------------------- /src/karmabot/main.py: -------------------------------------------------------------------------------- 1 | from slack_bolt.adapter.socket_mode import SocketModeHandler 2 | 3 | from karmabot.bot import app 4 | from karmabot.db.database import database 5 | from karmabot.settings import SLACK_APP_TOKEN 6 | 7 | 8 | def main(): 9 | database.connect() 10 | database.create_models() 11 | 12 | handler = SocketModeHandler(app, SLACK_APP_TOKEN) 13 | handler.start() 14 | 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | db: 5 | image: postgres:14.6 6 | restart: unless-stopped 7 | container_name: "karmabot_postgres" 8 | environment: 9 | - POSTGRES_USER=user42 10 | - POSTGRES_PASSWORD=pw42 11 | - POSTGRES_DB=karmabot 12 | ports: 13 | - "5432:5432" 14 | 15 | adminer: 16 | image: adminer:4.8.1 17 | restart: unless-stopped 18 | container_name: "karmabot_adminer" 19 | ports: 20 | - 8080:8080 21 | -------------------------------------------------------------------------------- /src/karmabot/exceptions.py: -------------------------------------------------------------------------------- 1 | class GetUserInfoException(Exception): 2 | """Excpetion if Slack API UserInfo could net retrieved""" 3 | 4 | pass 5 | 6 | 7 | class CommandNotFoundException(Exception): 8 | """Exception if a request Command is not found""" 9 | 10 | pass 11 | 12 | 13 | class CommandExecutionException(Exception): 14 | """Exception for failures during command execution""" 15 | 16 | pass 17 | 18 | 19 | class NotInitializedException(Exception): 20 | """Exception if required values are not initialized properly""" 21 | 22 | pass 23 | -------------------------------------------------------------------------------- /tests/commands/test_joke.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from karmabot.commands.joke import _get_closest_category 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "user_category, expected", 8 | [ 9 | ("all", "all"), 10 | ("neutral", "neutral"), 11 | ("chuck", "chuck"), 12 | ("", "all"), 13 | ("al", "all"), 14 | ("neutr", "neutral"), 15 | ("chuk", "chuck"), 16 | ("help", "all"), 17 | ], 18 | ) 19 | def test_get_closest_category(user_category, expected): 20 | assert _get_closest_category(user_category) == expected 21 | -------------------------------------------------------------------------------- /src/karmabot/__init__.py: -------------------------------------------------------------------------------- 1 | """The PyBites Karmabot for Slack.""" 2 | try: 3 | from importlib.metadata import PackageNotFoundError, version # type: ignore 4 | except ImportError: # pragma: no cover 5 | from importlib_metadata import PackageNotFoundError, version # type: ignore 6 | 7 | 8 | try: 9 | __version__ = version(__name__) 10 | except PackageNotFoundError: # pragma: no cover 11 | __version__ = "unknown" 12 | 13 | 14 | import logging 15 | 16 | logging.basicConfig( 17 | level=logging.INFO, 18 | format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s", # noqa E501 19 | datefmt="%m-%d %H:%M", 20 | handlers=[logging.StreamHandler()], 21 | ) 22 | 23 | logging.info("Karmabot started") 24 | -------------------------------------------------------------------------------- /.github/workflows/safety.yml: -------------------------------------------------------------------------------- 1 | name: Safety 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | branches: 10 | - develop 11 | 12 | jobs: 13 | safety: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: checkout repo content 18 | uses: actions/checkout@v3 19 | 20 | - name: setup python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: "3.10" 24 | architecture: x64 25 | 26 | - name: install nox 27 | run: pip install nox==2022.11.21 28 | 29 | - name: install poetry 30 | run: pip install poetry==1.3.2 31 | 32 | - name: run safety 33 | run: nox --sessions safety-3.10 34 | -------------------------------------------------------------------------------- /src/karmabot/db/karma_user.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | 3 | from karmabot.db.modelbase import SqlAlchemyBase 4 | 5 | 6 | class KarmaUser(SqlAlchemyBase): 7 | """Models a slack user with karma in the DB""" 8 | 9 | __tablename__ = "karma_user" 10 | 11 | user_id = sa.Column(sa.String, primary_key=True) 12 | username = sa.Column(sa.String) 13 | karma_points = sa.Column(sa.Integer, default=0) 14 | 15 | def formatted_user_id(self): 16 | """Formats user id for use in slack messages""" 17 | return f"<@{self.user_id}>" 18 | 19 | def __repr__(self): 20 | return ( 21 | f" ID: {self.user_id} | Username: {self.username} | " 22 | f"Karma-Points: {self.karma_points}" 23 | ) 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | pypi_release: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | 15 | - name: Install Poetry 16 | run: curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python 17 | 18 | - name: Add Poetry to path 19 | run: echo "${HOME}/.poetry/bin" >> $GITHUB_PATH 20 | 21 | - name: Install Poetry 22 | run: poetry install 23 | 24 | - name: Configure Poetry 25 | run: poetry config pypi-token.pypi "${{ secrets.PYPI_API_TOKEN }}" 26 | 27 | - name: Publish package 28 | run: poetry publish --build 29 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | branches: 10 | - develop 11 | 12 | jobs: 13 | coverage: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: checkout repo content 18 | uses: actions/checkout@v3 19 | 20 | - name: setup python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: "3.10" 24 | architecture: x64 25 | 26 | - name: install nox 27 | run: pip install nox==2022.11.21 28 | 29 | - name: install poetry 30 | run: pip install poetry==1.3.2 31 | 32 | - name: run coverage 33 | run: nox --sessions tests-3.10 coverage 34 | 35 | - name: codecov 36 | uses: codecov/codecov-action@v2 37 | -------------------------------------------------------------------------------- /src/karmabot/commands/feed.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | import feedparser 4 | 5 | # https://stackoverflow.com/a/28296087 6 | if hasattr(ssl, "_create_unverified_context"): 7 | ssl._create_default_https_context = ssl._create_unverified_context 8 | 9 | RSS = "https://pybit.es/feeds/all.rss.xml" 10 | MAX_ENTRIES = 5 11 | 12 | 13 | def get_pybites_last_entries(**kwargs): 14 | """Get the last 5 entries of PyBites blog (might take a bit)""" 15 | data = feedparser.parse(RSS) 16 | 17 | output = [] 18 | for item in data["entries"][:MAX_ENTRIES]: 19 | title = item["title"] 20 | published = item["published"] 21 | url = item["link"] 22 | output.append(f"{title} ({published})\n{url}\n") 23 | 24 | return "\n".join(output) 25 | 26 | 27 | if __name__ == "__main__": 28 | output = get_pybites_last_entries() 29 | print(output) 30 | -------------------------------------------------------------------------------- /src/karmabot/commands/tip.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import requests 4 | 5 | PLATFORM = "https://codechalleng.es" 6 | CC_ES = "CodeChalleng.es" 7 | TIPS_ENDPOINT = f"{PLATFORM}/api/tips" 8 | NEW_TIP_LINK = f"{PLATFORM}/inbox/new/pytip/" 9 | 10 | ADD_TIP = f"\nSource: {CC_ES} | Share more tips: {NEW_TIP_LINK}\n" 11 | 12 | 13 | def get_random_tip(**kwargs): 14 | """Print a random Python tip, quote or nugget from CodeChalleng.es""" 15 | resp = requests.get(TIPS_ENDPOINT, timeout=10) 16 | tips = resp.json() 17 | tip = random.choice(tips) 18 | msg = f"> {tip['tip']}\n" 19 | 20 | if tip["link"]: 21 | msg += f"\n{tip['link']}\n" 22 | 23 | if tip["code"]: 24 | msg += f"\n```{tip['code']}```\n" 25 | 26 | msg += ADD_TIP 27 | return msg 28 | 29 | 30 | if __name__ == "__main__": 31 | tip = get_random_tip() 32 | print(tip) 33 | -------------------------------------------------------------------------------- /src/karmabot/commands/add.py: -------------------------------------------------------------------------------- 1 | import karmabot.slack 2 | 3 | MSG = """Hey {username}, so you want to propose a new command eh? 4 | 5 | Awesome! Here are the steps: 6 | 1. Karmabot repo: https://github.com/pybites/karmabot 7 | 2. Fork the repo, make your branch. 8 | 3. Add your command script under the commands subdirectory. 9 | 4. Open a PR of your branch against PyBites repo. 10 | 5. Bob/Julian/Community to approve and merge it in. 11 | 6. Join the slack channel: #karmabot_dev 12 | 13 | Here is a walk-through video: 14 | https://www.youtube.com/watch?v=Yx9qYl6lmzM&t=2s 15 | 16 | Thanks! 17 | """ 18 | 19 | 20 | def add_command(**kwargs): 21 | """Instructions how to propose a new bot command""" 22 | user_id = kwargs.get("user_id") 23 | if not user_id: 24 | return None 25 | 26 | slack_id = karmabot.slack.get_slack_id(user_id) 27 | return MSG.format(username=slack_id) 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | branches: 10 | - develop 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ['3.10', '3.11'] 18 | fail-fast: false 19 | name: Python ${{ matrix.python-version }} 20 | steps: 21 | - name: checkout repo content 22 | uses: actions/checkout@v3 23 | 24 | - name: setup python 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | architecture: x64 29 | 30 | - name: install nox 31 | run: pip install nox==2022.11.21 32 | 33 | - name: install poetry 34 | run: pip install poetry==1.3.2 35 | 36 | - name: run tests 37 | run: nox --sessions tests-${{ matrix.python-version }} 38 | -------------------------------------------------------------------------------- /.github/workflows/pre_release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Test PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | test_pypi_release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | 16 | - name: Install Poetry 17 | run: curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python 18 | 19 | - name: Add Poetry to path 20 | run: echo "${HOME}/.poetry/bin" >> $GITHUB_PATH 21 | 22 | - name: Install Poetry 23 | run: poetry install 24 | 25 | - name: Set testpypi repo 26 | run: poetry config repositories.testpypi https://test.pypi.org/legacy/ 27 | 28 | - name: Set testpypi token 29 | run: poetry config pypi-token.testpypi ${{ secrets.TEST_PYPI_API_TOKEN }} 30 | 31 | - name: Publish package 32 | run: poetry publish --build -r testpypi 33 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from karmabot.settings import get_env_var 7 | 8 | 9 | @pytest.mark.parametrize("env_var", ["true", "false"]) 10 | def test_get_env_var(env_var): 11 | with patch.dict(os.environ, {"KARMABOT_TEST_MODE": env_var}): 12 | assert get_env_var("KARMABOT_TEST_MODE") == env_var 13 | 14 | 15 | def test_exception_env_variable_not_found(): 16 | with patch.dict(os.environ, {}, clear=True): 17 | with pytest.raises(KeyError): 18 | get_env_var("KARMABOT_TEST_MODE") 19 | 20 | 21 | def test_exception_env_variable_empty(): 22 | with patch.dict(os.environ, {"KARMABOT_TEST_MODE": ""}): 23 | with pytest.raises(ValueError): 24 | get_env_var("KARMABOT_TEST_MODE") 25 | 26 | 27 | def test_return_default_value_if_env_variable_not_set(): 28 | with patch.dict(os.environ, {}, clear=True): 29 | assert get_env_var("KARMABOT_TEST_MODE", default="false") == "false" 30 | -------------------------------------------------------------------------------- /src/karmabot/db/karma_note.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import sqlalchemy as sa 4 | 5 | from karmabot.db.modelbase import SqlAlchemyBase 6 | 7 | 8 | class KarmaNote(SqlAlchemyBase): 9 | """Models a simple note system in the DB""" 10 | 11 | __tablename__ = "karma_note" 12 | 13 | id = sa.Column( # noqa 14 | sa.BigInteger().with_variant(sa.Integer, "sqlite"), 15 | primary_key=True, 16 | autoincrement=True, 17 | ) 18 | user_id = sa.Column(sa.String, sa.ForeignKey("karma_user.user_id"), nullable=False) 19 | timestamp = sa.Column(sa.DateTime, default=datetime.datetime.now, nullable=False) 20 | note = sa.Column(sa.String) 21 | 22 | def __repr__(self): 23 | return ( 24 | f"[KarmaNote] ID: {self.id} | {self.user_id} -> " 25 | f"{self.timestamp} | Note: {self.note}" 26 | ) 27 | 28 | def __str__(self): 29 | return ( 30 | f"(ID: {self.id}) from {self.timestamp.strftime('%Y-%m-%d, %H:%M')}: " 31 | f"{self.note}." 32 | ) 33 | -------------------------------------------------------------------------------- /src/karmabot/commands/joke.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | from typing import Union 3 | 4 | import pyjokes 5 | 6 | import karmabot.slack 7 | 8 | PYJOKE_HREF = "" 9 | CATEGORIES = list(pyjokes.jokes_en.jokes_en.keys()) 10 | 11 | 12 | def joke(**kwargs) -> Union[str, None]: 13 | """Posts a random PyJoke in the public or private channel""" 14 | user_id, text = kwargs.get("user_id"), kwargs.get("text") 15 | 16 | if user_id is not None and text is not None: 17 | slack_id = karmabot.slack.get_slack_id(user_id) 18 | words = text.lower().split() 19 | 20 | user_category = words[-1] if len(words) > 2 else "" 21 | category = _get_closest_category(user_category) 22 | else: 23 | return None 24 | 25 | joke_text = pyjokes.get_joke(category=category) 26 | 27 | return f"Hey {slack_id}, here is a {PYJOKE_HREF} for you: _{joke_text}_" 28 | 29 | 30 | def _get_closest_category(input_category: str): 31 | category = difflib.get_close_matches(input_category, CATEGORIES) 32 | category = category[0] if category else "all" 33 | 34 | return category 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 PyBites 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/karmabot/db/karma_transaction.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import sqlalchemy as sa 4 | from sqlalchemy import Integer 5 | 6 | from karmabot.db.modelbase import SqlAlchemyBase 7 | 8 | 9 | class KarmaTransaction(SqlAlchemyBase): 10 | """Models a karma transaction in the DB""" 11 | 12 | __tablename__ = "karma_transaction" 13 | 14 | id: int = sa.Column( # type: ignore # noqa 15 | sa.BigInteger().with_variant(Integer, "sqlite"), 16 | primary_key=True, 17 | autoincrement=True, 18 | ) 19 | giver_id = sa.Column(sa.String, sa.ForeignKey("karma_user.user_id"), nullable=False) 20 | receiver_id = sa.Column( 21 | sa.String, sa.ForeignKey("karma_user.user_id"), nullable=False 22 | ) 23 | 24 | # data 25 | timestamp = sa.Column(sa.DateTime, default=datetime.datetime.now, nullable=False) 26 | channel = sa.Column(sa.String) 27 | karma = sa.Column(sa.Integer, nullable=False) 28 | 29 | __table_args__ = (sa.CheckConstraint(giver_id != receiver_id),) 30 | 31 | def __repr__(self): 32 | return ( 33 | f"[KarmaTransaction] ID: {self.id} | {self.giver_id} -> " 34 | f"{self.receiver_id} | {self.timestamp} | Karma: {self.karma}" 35 | ) 36 | -------------------------------------------------------------------------------- /tests/commands/test_topchannels.py: -------------------------------------------------------------------------------- 1 | # def _channel_score(channel): 2 | # channel_info = channel["channel"] 3 | # return calc_channel_score( 4 | # Channel( 5 | # channel_info["id"], 6 | # channel_info["name"], 7 | # channel_info["purpose"]["value"], 8 | # len(channel_info["members"]), 9 | # float(channel_info["latest"]["ts"]), 10 | # channel_info["latest"].get("subtype"), 11 | # ) 12 | # ) 13 | 14 | 15 | # def test_channel_score(mock_slack_api_call, frozen_now): 16 | # most_recent = SLACK_CLIENT.api_call("channels.info", channel="CHANNEL42") 17 | # less_recent = SLACK_CLIENT.api_call("channels.info", channel="CHANNEL43") 18 | # assert _channel_score(most_recent) > _channel_score(less_recent) 19 | 20 | 21 | # @patch.dict(os.environ, {"SLACK_KARMA_INVITE_USER_TOKEN": "xoxp-162..."}) 22 | # @patch.dict(os.environ, {"SLACK_KARMA_BOTUSER": "U5Z6KGX4L"}) 23 | # def test_ignore_message_subtypes(mock_slack_api_call, frozen_now): 24 | # latest_ignored = SLACK_CLIENT.api_call("channels.info", channel="SOMEJOINS") 25 | # all_ignored = SLACK_CLIENT.api_call("channels.info", channel="ONLYJOINS") 26 | # assert _channel_score(latest_ignored) > 0 27 | # assert _channel_score(all_ignored) == 0 28 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import karmabot.bot # noqa 4 | from karmabot.db.database import database 5 | from karmabot.db.karma_user import KarmaUser 6 | from karmabot.karma import Karma 7 | 8 | 9 | def test_karma_user_repr(karma_users): 10 | assert ( 11 | repr(karma_users[0]) 12 | == " ID: ABC123 | Username: pybob | Karma-Points: 392" 13 | ) 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "test_user_id, expected", 18 | [("ABC123", "pybob"), ("EFG123", "Julian Sequeira"), ("XYZ123", "clamytoe")], 19 | ) 20 | @pytest.mark.usefixtures("mock_filled_db_session") 21 | def test_lookup_username(test_user_id, expected): 22 | with database.session_manager() as session: 23 | karma_user = session.get(KarmaUser, test_user_id) 24 | assert karma_user.username == expected 25 | 26 | 27 | @pytest.mark.usefixtures("mock_empty_db_session", "users_profile_get_fake_user") 28 | def test_create_karma_user(): 29 | karma = Karma("ABC123", "XYZ123", "CHANNEL42") 30 | assert karma.giver.username == "pybob" 31 | assert karma.receiver.username == "clamytoe" 32 | 33 | with database.session_manager() as session: 34 | first = session.get(KarmaUser, "ABC123") 35 | second = session.get(KarmaUser, "XYZ123") 36 | 37 | assert first.username == "pybob" 38 | assert second.username == "clamytoe" 39 | -------------------------------------------------------------------------------- /src/karmabot/commands/score.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | import karmabot.slack 4 | from karmabot.db.database import database 5 | from karmabot.db.karma_user import KarmaUser 6 | 7 | TOP_NUMBER = 10 8 | 9 | 10 | def get_karma(**kwargs): 11 | """Get your current karma score""" 12 | user_id = kwargs.get("user_id") 13 | slack_id = karmabot.slack.get_slack_id(user_id) 14 | 15 | with database.session_manager() as session: 16 | karma_user = session.get(KarmaUser, user_id) 17 | 18 | if not karma_user: 19 | return "User not found" 20 | 21 | if karma_user.karma_points == 0: 22 | return "Sorry, you don't have any karma yet" 23 | 24 | return f"Hey {slack_id}, your current karma is {karma_user.karma_points}" 25 | 26 | 27 | def top_karma(**kwargs): 28 | """Get the PyBites members with most karma""" 29 | output = ["PyBites members with most karma:"] 30 | 31 | with database.session_manager() as session: 32 | statement = ( 33 | select(KarmaUser).order_by(KarmaUser.karma_points.desc()).limit(TOP_NUMBER) 34 | ) 35 | top_users = session.execute(statement).scalars().all() 36 | 37 | if top_users: 38 | for top_user in top_users: 39 | output.append(f"{top_user.username:<20} -> {top_user.karma_points}") 40 | ret = "\n".join(output) 41 | return f"```{ret}```" 42 | 43 | return "Sorry, no users found" 44 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: | 3 | [pre-commit.ci] auto fixes from pre-commit.com hooks 4 | autofix_prs: true 5 | autoupdate_branch: 'develop' 6 | autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' 7 | autoupdate_schedule: monthly 8 | skip: [] 9 | submodules: false 10 | 11 | repos: 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.4.0 14 | hooks: 15 | - id: check-yaml 16 | - id: end-of-file-fixer 17 | - id: trailing-whitespace 18 | - id: debug-statements 19 | 20 | - repo: https://github.com/pycqa/flake8 21 | rev: 6.1.0 22 | hooks: 23 | - id: flake8 24 | additional_dependencies: 25 | - "flake8-bandit" 26 | - "flake8-bugbear" 27 | - "flake8-blind-except" 28 | - "flake8-builtins" 29 | - "flake8-logging-format" 30 | - "flake8-debugger" 31 | - "flake8-use-fstring" 32 | 33 | - repo: https://github.com/pycqa/isort 34 | rev: 5.12.0 35 | hooks: 36 | - id: isort 37 | args: ["--profile", "black", "--filter-files"] 38 | 39 | - repo: https://github.com/psf/black 40 | rev: 23.7.0 41 | hooks: 42 | - id: black 43 | language_version: python3 44 | 45 | - repo: https://github.com/pre-commit/mirrors-mypy 46 | rev: v1.5.1 47 | hooks: 48 | - id: mypy 49 | args: [--no-strict-optional, --ignore-missing-imports] 50 | 51 | - repo: https://github.com/asottile/pyupgrade 52 | rev: v3.10.1 53 | hooks: 54 | - id: pyupgrade 55 | -------------------------------------------------------------------------------- /src/karmabot/commands/template.py: -------------------------------------------------------------------------------- 1 | """ 2 | 0. Save/copy this file to a new file under commands/ 3 | 4 | 1. Add logic to my_command and rename it to something more meaningful. 5 | Optionally you can access user, channel, text from the passed in **kwargs (don't remove this). 6 | 7 | 2. Add a useful docstring to your renamed my_command. 8 | 9 | 3. Return a message string that the command/bot should post to the channel. 10 | You probably want to add some code under __main__ to make sure the function does what you want ... 11 | 12 | 4. In bot/slack.py import the script, e.g.: from commands.template import my_command 13 | 14 | 5. Add the command to the appropriate dict: ADMIN_BOT_COMMANDS, PUBLIC_BOT_COMMANDS or PRIVATE_BOT_COMMANDS 15 | (public = for us in channels, private = for use in @karmabot DM) 16 | 17 | 6. PR your work. Thanks 18 | """ 19 | 20 | 21 | def my_command(**kwargs: dict) -> str: # type: ignore 22 | """Text that will appear in the help section""" 23 | # kwargs will hold user, channel, text (from a Slack message object) 24 | 25 | # use them like this, or just delete these line: 26 | user_id = kwargs.get("user_id") # noqa: F841 27 | channel_id = kwargs.get("channel") # noqa: F841 28 | msg_text = kwargs.get("text") # noqa: F841 29 | 30 | # return a message string 31 | msg = "Replace me!" 32 | return msg 33 | 34 | 35 | if __name__ == "__main__": 36 | #  standalone test 37 | user, channel, text = "ABCD123", "XYZ789", "some message" 38 | kwargs = dict(user=user, channel=channel, text=text) 39 | output = my_command(**kwargs) # type: ignore 40 | print(output) 41 | -------------------------------------------------------------------------------- /src/karmabot/slack.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | 4 | SLACK_ID_PATTERN = re.compile(r"^<@[^>]+>$") 5 | 6 | 7 | class MessageChannelType(Enum): 8 | DM = "im" 9 | CHANNEL = "channel" 10 | GROUP = "group" 11 | 12 | 13 | def get_slack_id(user_id: str) -> str: 14 | """ 15 | Formats a plain user_id (ABC123XYZ) to use slack identity 16 | Slack format <@ABC123XYZ> for highlighting users 17 | 18 | :param user_id: Plain user id 19 | :type user_id: str 20 | :return: Slack formatted user_id 21 | :rtype: str 22 | """ 23 | if SLACK_ID_PATTERN.match(user_id): 24 | return user_id 25 | 26 | return f"<@{user_id}>" 27 | 28 | 29 | def get_user_id(user_id: str) -> str: 30 | """ 31 | Formats the user_id to a plain format removing any <, < or @ 32 | :param user_id: slack id format 33 | :type user_id: str 34 | :return: plain user id 35 | :rtype: str 36 | """ 37 | return user_id.strip("<>@") 38 | 39 | 40 | def get_available_username(user_profile): 41 | """ 42 | Determines the username based on information available from slack. 43 | First information is used in the following order: 44 | 1) display_name, 2) real_name 45 | 46 | :param user_profile: Slack user_profile dict 47 | :return: human-readable username 48 | """ 49 | 50 | display_name = user_profile["display_name_normalized"] 51 | if display_name: 52 | return display_name 53 | 54 | real_name = user_profile["real_name_normalized"] 55 | if real_name: 56 | return real_name 57 | 58 | raise ValueError("User Profile data missing name information") 59 | -------------------------------------------------------------------------------- /tests/test_slack.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from karmabot.slack import get_available_username, get_slack_id, get_user_id 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "test_profile, expected", 8 | [ 9 | ({"display_name_normalized": "pybob", "real_name_normalized": "Bob"}, "pybob"), 10 | ( 11 | { 12 | "display_name_normalized": None, 13 | "real_name_normalized": "Julian Sequeira", 14 | }, 15 | "Julian Sequeira", 16 | ), 17 | ], 18 | ) 19 | def test_get_available_username(test_profile, expected): 20 | assert get_available_username(test_profile) == expected 21 | 22 | 23 | def test_get_available_username_wrong_format(): 24 | with pytest.raises(ValueError, match="User Profile data missing name information"): 25 | get_available_username( 26 | {"display_name_normalized": None, "real_name_normalized": None} 27 | ) 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "test_id, expected", 32 | [ 33 | ("ABC123", "<@ABC123>"), 34 | ("EFG123", "<@EFG123>"), 35 | ("<@XYZ123>", "<@XYZ123>"), 36 | ("<@ABC123>", "<@ABC123>"), 37 | ], 38 | ) 39 | def test_get_slack_id(test_id, expected): 40 | assert get_slack_id(test_id) == expected 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "test_id, expected", 45 | [ 46 | ("<@ABC123>", "ABC123"), 47 | ("<@EFG123>", "EFG123"), 48 | ("XYZ123", "XYZ123"), 49 | ("ABC123", "ABC123"), 50 | ("ABC<>@123", "ABC<>@123"), 51 | ], 52 | ) 53 | def test_get_user_id(test_id, expected): 54 | assert get_user_id(test_id) == expected 55 | -------------------------------------------------------------------------------- /src/karmabot/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from pathlib import Path 5 | from typing import Union 6 | 7 | from dotenv import load_dotenv 8 | 9 | 10 | def get_env_var(env_var: str, default: Union[str, None] = None) -> str: 11 | env_var_value = os.environ.get(env_var) 12 | 13 | # explicit check for None as None is returned by environ.get for non existing keys 14 | if env_var_value is None: 15 | if default is not None: 16 | return default 17 | 18 | raise KeyError( 19 | f"{env_var} was not found. Please check your .karmabot file as well as the README.md." 20 | ) 21 | 22 | if not env_var_value: 23 | raise ValueError( 24 | f"{env_var} was found but seems empty. Please check your .karmabot file as well as the README.md." 25 | ) 26 | 27 | return env_var_value 28 | 29 | 30 | dotenv_path = Path(".").resolve() / ".karmabot" 31 | if dotenv_path.exists(): 32 | logging.info("Loading environmental variables from: '%s'", dotenv_path) 33 | load_dotenv(dotenv_path) 34 | 35 | # Environment variables 36 | KARMABOT_ID = get_env_var("KARMABOT_SLACK_USER") 37 | DATABASE_URL = get_env_var("KARMABOT_DATABASE_URL") 38 | SLACK_APP_TOKEN = get_env_var("KARMABOT_SLACK_APP_TOKEN") 39 | SLACK_BOT_TOKEN = get_env_var("KARMABOT_SLACK_BOT_TOKEN") 40 | TEST_MODE = bool(get_env_var("KARMABOT_TEST_MODE") == "true") 41 | logging.info("Test mode enabled: %s", TEST_MODE) 42 | 43 | # Slack 44 | GENERAL_CHANNEL = get_env_var("KARMABOT_GENERAL_CHANNEL") 45 | LOG_CHANNEL = get_env_var("KARMABOT_LOG_CHANNEL") 46 | ADMINS = get_env_var("KARMABOT_ADMINS") 47 | ADMINS = ADMINS.split(",") # type: ignore 48 | 49 | # Karma 50 | # the first +/- is merely signaling, start counting (regex capture) 51 | # from second +/- onwards, so bob++ adds 1 point, bob+++ = +2, etc 52 | KARMA_ACTION_PATTERN = re.compile( 53 | r"(?:^| )(\S{2,}?)\s?[\+\-]([\+\-]+)", flags=re.MULTILINE 54 | ) 55 | MAX_POINTS = 5 56 | -------------------------------------------------------------------------------- /src/karmabot/commands/update_username.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import karmabot.bot as bot 4 | import karmabot.slack 5 | from karmabot.db.database import database 6 | from karmabot.db.karma_user import KarmaUser 7 | 8 | 9 | def update_username(**kwargs): 10 | """Changes the Username""" 11 | user_id = kwargs.get("user_id") 12 | if user_id: 13 | user_id = user_id.strip("<>@") 14 | 15 | with database.session_manager() as session: 16 | karma_user: KarmaUser = session.get(KarmaUser, user_id) 17 | 18 | if not karma_user: 19 | return "User not found" 20 | 21 | old_username = karma_user.username 22 | 23 | response = bot.app.client.users_profile_get(user=user_id) 24 | status = response.status_code 25 | 26 | if status != 200: 27 | logging.error("Cannot get user info for %s - API error: %s", user_id, status) 28 | return "Sorry, I could not retrieve your user information from the slack API :(" 29 | 30 | user_profile = response.data["profile"] 31 | new_username = karmabot.slack.get_available_username(user_profile) 32 | 33 | if old_username == new_username: 34 | return ( 35 | f"Sorry, you have not updated your username: {old_username}. \n" 36 | "Please update your real-name or display-name in your Slack " 37 | "profile and retry." 38 | ) 39 | 40 | with database.session_manager() as session: 41 | karma_user.username = new_username 42 | session.commit() 43 | 44 | return ( 45 | f"Sucessfully updated your KarmaUser name " 46 | f"from '{old_username}' to '{new_username}'!" 47 | ) 48 | 49 | 50 | def get_user_name(**kwargs): 51 | """Shows the current Username""" 52 | user_id = kwargs.get("user_id") 53 | if user_id: 54 | user_id = user_id.strip("<>@") 55 | 56 | with database.session_manager() as session: 57 | karma_user: KarmaUser = session.get(KarmaUser, user_id) 58 | 59 | if not karma_user: 60 | return "Sorry, you are not yet known to karmabot. Try to give some Karma! :)" 61 | username = karma_user.username 62 | 63 | return f"Your current username for karmabot is '{username}'" 64 | -------------------------------------------------------------------------------- /src/karmabot/commands/doc.py: -------------------------------------------------------------------------------- 1 | """A Karmabot pydoc interface. 2 | """ 3 | import contextlib 4 | import io 5 | import pydoc 6 | 7 | import karmabot.slack 8 | 9 | MSG_APOLOGY = """Sorry {username}, I got nothing for "{text}". 10 | 11 | I'll do a keyword search for "{text}" if you add -k before {text}. 12 | 13 | Try "topics" or "modules" for more general help. 14 | """ 15 | 16 | MSG_FOUNDIT = """Good news {username}, I found the following about {text}: 17 | ``` 18 | {result} 19 | ``` 20 | """ 21 | 22 | 23 | MSG_HELP = """ 24 | pydoc [-k keyword] [module_path_or_topic|topics|modules|help] 25 | 26 | You can use pydoc to look up all sorts of pythonic things! 27 | 28 | Use this to get the docstring for a module: 29 | 30 | pydoc list 31 | 32 | Or do a keyword search to get a list of modules that match: 33 | 34 | pydoc -k keyword 35 | 36 | Get a list of modules: 37 | 38 | pydoc modules 39 | 40 | A list of python language topics, super interesting: 41 | 42 | pydoc topics 43 | 44 | And information about the specific listed topics: 45 | 46 | pydoc LOOPING 47 | 48 | """ 49 | 50 | 51 | def doc_command(**kwargs) -> str: 52 | """Browse and search python documentation, "pydoc help" """ 53 | user_id = str(kwargs.get("user_id")) 54 | text = str(kwargs.get("text")) 55 | 56 | if len(text) == 0 or text.lower() == "help": 57 | return MSG_HELP 58 | 59 | apropos = "-k" in text 60 | 61 | if "-" in text and not apropos: # weed out switches that aren't -k 62 | return MSG_HELP 63 | 64 | output = io.StringIO() 65 | with contextlib.redirect_stdout(output): 66 | if apropos: 67 | pydoc.apropos(text.partition("-k")[-1]) 68 | else: 69 | help(text) 70 | result = output.getvalue() 71 | 72 | slack_id = karmabot.slack.get_slack_id(user_id) 73 | 74 | if result.startswith("No"): 75 | return MSG_APOLOGY.format(username=slack_id, text=text) 76 | 77 | return MSG_FOUNDIT.format(username=slack_id, text=text, result=result) 78 | 79 | 80 | if __name__ == "__main__": 81 | import sys 82 | 83 | kwargs = {"user": "Erik", "channel": "#unix", "text": " ".join(sys.argv[1:])} 84 | print(doc_command(**kwargs)) 85 | -------------------------------------------------------------------------------- /src/karmabot/commands/welcome.py: -------------------------------------------------------------------------------- 1 | """ private command, not callable """ 2 | from random import choice 3 | 4 | import karmabot.slack 5 | from karmabot.settings import KARMABOT_ID, get_env_var 6 | 7 | # thanks Erik! 8 | WELCOME_MSG = """Welcome {user} ++! 9 | 10 | Introduce yourself in #general if you like ... 11 | - What do you use Python for? 12 | - What is your day job? 13 | - And: >>> random.choice(pybites_init_questions) 14 | {welcome_question} 15 | 16 | Although you will meet some awesome folks here, you can also talk to me :) 17 | Type `help` here to get started ... 18 | 19 | Enjoy PyBites Slack and keep calm and code in Python! 20 | 21 | {admins} 22 | """ 23 | # some Pythonic welcome questions 24 | WELCOME_QUESTIONS = """How did you use Python for the first time? 25 | What is your favorite Python module? 26 | What was the last Python book you read? 27 | Did you go to Pycon? If so what was the best you got out of it? 28 | Do you have any particular interest or hobby? 29 | How did you hear about PyBites? 30 | What is your favorite software principle of the Zen of Python (import this) 31 | Are you a 100 Days of Code survivor or planning to take the challenge? 32 | What is your favorite TV show, movie or book? 33 | How many Christopher Nolan movies did you see? If > 1 favorite? 34 | If you were to build a chatbot what would it do? 35 | AI, hype or life threatening? 36 | How do you currently use Python? 37 | Are you teaching Python or inspire to do so? 38 | Australia or Spain? 39 | Star Trek or Star Wars? 40 | Tabs or spaces? (be careful!) 41 | Do you use test drive development (TDD)? 42 | What is your favorite editor? 43 | What other programming languages do you know and/or use?""" 44 | 45 | 46 | def _get_admins() -> str: 47 | admins_env = get_env_var("KARMABOT_ADMINS", default=KARMABOT_ID).split(",") 48 | admins = ", ".join(f"<@{admin}>" for admin in admins_env) 49 | return " and".join(admins.rsplit(",", maxsplit=1)) 50 | 51 | 52 | def welcome_user(user_id: str) -> str: 53 | """Welcome a new PyBites community member""" 54 | questions = WELCOME_QUESTIONS.split("\n") 55 | random_question = choice(questions) # noqa: S311 56 | slack_id = karmabot.slack.get_slack_id(user_id) 57 | admins = _get_admins() 58 | return WELCOME_MSG.format( 59 | user=slack_id, welcome_question=random_question, admins=admins 60 | ) 61 | -------------------------------------------------------------------------------- /src/karmabot/commands/control.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List 3 | 4 | import karmabot.bot as bot 5 | from karmabot.slack import get_user_id 6 | 7 | 8 | def join_public_channels(**kwargs) -> str: 9 | """Makes the bot join all public channels he is not in yet""" 10 | response = bot.app.client.conversations_list( 11 | exclude_archived=True, types="public_channel" 12 | ) 13 | if response["ok"]: 14 | public_channels = response["channels"] 15 | else: 16 | error = "Could not retrieve public channel list" 17 | logging.error(error) 18 | return error 19 | 20 | channels_joined = [] 21 | if not public_channels: 22 | return "Could not find any public channels" 23 | 24 | for channel in public_channels: 25 | if not channel["is_member"]: # only join channels you are not already in 26 | bot.app.client.conversations_join(channel=channel["id"]) 27 | bot.app.client.chat_postMessage( 28 | text="Karmabot is here! :wave:", channel=channel["id"] 29 | ) 30 | channels_joined.append(channel["name"]) 31 | 32 | if channels_joined: 33 | channels_text = ",".join(channels_joined) 34 | return f"I joined the following public channels: {channels_text}" 35 | 36 | return "There were no new public channels to join!" 37 | 38 | 39 | def your_id(**kwargs) -> str: 40 | """Shows the user id of karmabot""" 41 | message = kwargs.get("text", "") 42 | bot_slack_id = message.split()[0] 43 | bot_user_id = get_user_id(bot_slack_id) 44 | 45 | if bot_user_id: 46 | return f"My user id is: {bot_user_id}" 47 | 48 | return "Sorry could not retrieve my user id" 49 | 50 | 51 | def general_channel_id(**kwargs) -> str: 52 | """Shows the channel id of the general channel""" 53 | response = bot.app.client.conversations_list( 54 | exclude_archived=True, types="public_channel" 55 | ) 56 | 57 | if not response["ok"]: 58 | logging.error('Error for API call "channels.list": %s', response["error"]) 59 | return "I am truly sorry but something went wrong ;(" 60 | 61 | channels: List[Dict] = response["channels"] 62 | for channel in channels: 63 | if channel["is_general"]: 64 | general_id = channel["id"] 65 | return f"The general channel id is: {general_id}" 66 | 67 | return "Sorry, could not find the general channel id :(" 68 | -------------------------------------------------------------------------------- /src/karmabot/db/database.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from contextlib import contextmanager 4 | 5 | import sqlalchemy as sa 6 | import sqlalchemy.orm as orm 7 | from sqlalchemy.exc import OperationalError 8 | 9 | from karmabot import settings 10 | from karmabot.db.modelbase import SqlAlchemyBase 11 | from karmabot.exceptions import NotInitializedException 12 | 13 | 14 | class Database: 15 | """Class for handlading the database connection and provide access to sessions""" 16 | 17 | def __init__( 18 | self, connection_string: str = settings.DATABASE_URL, echo: bool = False 19 | ) -> None: 20 | self.connection_string = connection_string 21 | self.echo = echo 22 | 23 | self._engine = None 24 | self._SessionFactory = None # noqa 25 | 26 | def connect(self): 27 | """Sets up connection to DB and initializes models""" 28 | logging.debug("Connecting to DB with %s", self.connection_string) 29 | 30 | self._engine = sa.create_engine(self.connection_string, echo=self.echo) 31 | try: 32 | self._engine.connect() 33 | except OperationalError: 34 | logging.error("Database connection failed.") 35 | sys.exit(1) 36 | 37 | logging.info("DB connection successful") 38 | self._SessionFactory = orm.sessionmaker(bind=self.engine) 39 | 40 | def create_models(self): 41 | import karmabot.db.__all_models # noqa: F401 42 | 43 | if not self._engine: 44 | raise NotInitializedException("SqlAlchemy engine is not initialized") 45 | SqlAlchemyBase.metadata.create_all(self._engine) 46 | 47 | @contextmanager 48 | def session_manager(self): 49 | if not self._SessionFactory: 50 | raise NotInitializedException("SessionFactory is not initialized") 51 | 52 | session = self._SessionFactory() 53 | 54 | try: 55 | yield session 56 | except Exception: 57 | logging.error("Rollback database transaction") 58 | session.rollback() 59 | raise 60 | finally: 61 | logging.debug("Closing database connection") 62 | session.close() 63 | 64 | @property 65 | def session(self): 66 | if not self._SessionFactory: 67 | raise NotInitializedException("SessionFactory is not initialized") 68 | return self._SessionFactory() 69 | 70 | @property 71 | def engine(self): 72 | if not self._engine: 73 | raise NotInitializedException("SqlAlchemy engine is not initialized") 74 | return self._engine 75 | 76 | 77 | database = Database() 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/python 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 5 | 6 | ### Python ### 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # End of https://www.toptal.com/developers/gitignore/api/python 141 | /tmp 142 | *pyc 143 | *swp 144 | *.db 145 | .*cache* 146 | *.mypy_cache 147 | *.nox 148 | .isort.cfg 149 | .vscode 150 | 151 | data 152 | .karmabot 153 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "karmabot" 3 | version = "3.2.0" 4 | authors = ["PyBites ", "Patrick-Oliver Groß "] 5 | classifiers = [ 6 | "License :: OSI Approved :: MIT License", 7 | "Programming Language :: Python :: 3", 8 | "Programming Language :: Python :: 3 :: Only", 9 | "Programming Language :: Python :: 3.10", 10 | "Programming Language :: Python :: 3.11", 11 | "Topic :: Communications :: Chat", 12 | ] 13 | description = "PyBites Karmabot - A Python based Slack Chatbot for Community interaction" 14 | homepage = "https://github.com/PyBites-Open-Source/karmabot" 15 | keywords = ["karmabot"] 16 | license = "MIT" 17 | maintainers = ["Patrick-Oliver Groß "] 18 | readme = "README.md" 19 | repository = "https://github.com/PyBites-Open-Source/karmabot" 20 | 21 | [tool.poetry.dependencies] 22 | SQLAlchemy = "^2.0.1" 23 | feedparser = "^6.0.10" 24 | freezegun = "^1.2.2" 25 | humanize = "^4.5.0" 26 | importlib-metadata = "^3.7.3" 27 | psycopg2-binary = "^2.9.5" 28 | pyjokes = "^0.6.0" 29 | python = "^3.10" 30 | python-dotenv = "^0.21.1" 31 | requests = "^2.31.0" 32 | slack-bolt = "^1.6.1" 33 | slack-sdk = "^3.7.0" 34 | 35 | [tool.poetry.scripts] 36 | karmabot = "karmabot.main:main" 37 | 38 | [tool.poetry.group.dev.dependencies] 39 | black = "24.3.0" 40 | codecov = "^2.1.13" 41 | coverage = {extras = ["toml"], version = "^7.2.1"} 42 | flake8 = "^6.0.0" 43 | flake8-bandit = "^4.1.1" 44 | flake8-blind-except = "^0.2.1" 45 | flake8-bugbear = "^23.1.20" 46 | flake8-builtins = "^2.1.0" 47 | flake8-debugger = "^4.1.2" 48 | flake8-logging-format = "^0.9.0" 49 | flake8-use-fstring = "^1.4" 50 | isort = "^5.12.0" 51 | mypy = "^0.991" 52 | nox = "^2022.11.21" 53 | pre-commit = "^3.0.3" 54 | pytest = "^7.2.1" 55 | pytest-cov = "^4.0.0" 56 | pytest-mock = "^3.10.0" 57 | safety = "^2.3.5" 58 | 59 | [tool.coverage.paths] 60 | source = ["src", "*/site-packages"] 61 | 62 | [tool.coverage.run] 63 | branch = true 64 | omit = [ 65 | "src/karmabot/__main__.py", 66 | "src/karmabot/__init__.py", 67 | "src/karmabot/main.py", 68 | "src/karmabot/db/__all_models.py", 69 | "src/karmabot/db/modelsbase.py", 70 | ] 71 | source = ["karmabot"] 72 | 73 | [tool.coverage.report] 74 | show_missing = true 75 | 76 | [tool.black] 77 | exclude = ''' 78 | ( 79 | /( 80 | \.eggs # exclude a few common directories in the 81 | | \.git # root of the project 82 | | \.hg 83 | | \.mypy_cache 84 | | \.nox 85 | | \.venv 86 | | _build 87 | | buck-out 88 | | build 89 | | dist 90 | ) 91 | ) 92 | ''' 93 | include = '\.pyi?$' 94 | line-length = 88 95 | target-version = ["py310", "py311"] 96 | 97 | [tool.isort] 98 | known_third_party = "dotenv,feedparser,humanize,pyjokes,pytest,requests,sqlalchemy,freezegun,slack_bolt,slack_sdk" 99 | profile = "black" 100 | 101 | [tool.mypy] 102 | ignore_missing_imports = true 103 | mypy_path = "src,tests" 104 | pretty = true 105 | show_column_numbers = true 106 | show_error_codes = true 107 | show_error_context = true 108 | 109 | [[tool.mypy.overrides]] 110 | ignore_missing_imports = true 111 | module = [ 112 | "freezegun", 113 | "requests", 114 | ] 115 | 116 | [build-system] 117 | build-backend = "poetry.core.masonry.api" 118 | requires = ["poetry_core"] 119 | -------------------------------------------------------------------------------- /tests/test_bot.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from karmabot.bot import karma_action, reply_commands, reply_help 6 | from karmabot.commands.welcome import welcome_user 7 | from karmabot.settings import KARMABOT_ID 8 | 9 | 10 | def _fake_say(text, channel=None): 11 | print(text) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "test_message, expected", 16 | [ 17 | ( 18 | {"text": "<@EFG123> +++", "user": "ABC123", "channel": "FAKE_CHANNEL"}, 19 | "Julian Sequeira's karma increased to 125", 20 | ), 21 | ( 22 | {"text": "<@XYZ123> +++++", "user": "ABC123", "channel": "FAKE_CHANNEL"}, 23 | "clamytoe's karma increased to 424", 24 | ), 25 | ], 26 | ) 27 | @pytest.mark.usefixtures("mock_filled_db_session", "save_transaction_disabled") 28 | def test_karma_action(capfd, test_message, expected): 29 | karma_action(test_message, _fake_say) # type: ignore 30 | out = capfd.readouterr()[0] 31 | assert out.strip() == expected 32 | 33 | 34 | def test_reply_help(capfd): 35 | message = { 36 | "text": "<@ABC123> help", 37 | "user": "XYZ789", 38 | "channel_type": "public_channel", 39 | } 40 | 41 | def fake_print(text, channel): 42 | print(text) 43 | 44 | reply_help(message, fake_print) # type: ignore 45 | out = capfd.readouterr()[0] 46 | assert "age" in out 47 | assert "Print PyBites age in days" in out 48 | 49 | 50 | def test_reply_commands_admin(): 51 | pass 52 | 53 | 54 | def test_reply_commands_public(): 55 | pass 56 | 57 | 58 | def test_reply_commands_private(): 59 | pass 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "test_message, expected", 64 | [ 65 | ( 66 | { 67 | "text": f"<@{KARMABOT_ID}> yada yada", 68 | "user": "FAKE_USER", 69 | "channel": "FAKE_CHANNEL", 70 | "channel_type": "public_channel", 71 | }, 72 | 'Sorry <@FAKE_USER>, there is no command "yada"', 73 | ), 74 | ], 75 | ) 76 | def test_reply_commands_unknown(capfd, test_message, expected): 77 | reply_commands(test_message, print) # type: ignore 78 | out = capfd.readouterr()[0] 79 | assert out.strip() == expected 80 | 81 | 82 | @patch("karmabot.commands.welcome.choice") 83 | def test_welcome_new_user(choice_mock): 84 | choice_mock.return_value = "What is your favorite Python module?" 85 | actual_msg = welcome_user("bob") 86 | expected_msg = """Welcome <@bob> ++! 87 | 88 | Introduce yourself in #general if you like ... 89 | - What do you use Python for? 90 | - What is your day job? 91 | - And: >>> random.choice(pybites_init_questions) 92 | What is your favorite Python module? 93 | 94 | Although you will meet some awesome folks here, you can also talk to me :) 95 | Type `help` here to get started ... 96 | 97 | Enjoy PyBites Slack and keep calm and code in Python! 98 | 99 | <@FAKE_ADMIN1>, <@FAKE_ADMIN2> and <@FAKE_ADMIN3>""" 100 | assert [line.lstrip() for line in actual_msg.strip().splitlines()] == [ 101 | line.lstrip() for line in expected_msg.strip().splitlines() 102 | ] 103 | 104 | 105 | def autojoin_new_channels(): 106 | pass 107 | 108 | 109 | def test_perform_command(): 110 | pass 111 | 112 | 113 | def test_create_commands_table(): 114 | pass 115 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | import pytest 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.orm import Session 6 | 7 | from karmabot.db.database import database 8 | from karmabot.db.karma_note import KarmaNote 9 | from karmabot.db.karma_transaction import KarmaTransaction 10 | from karmabot.db.karma_user import KarmaUser 11 | from karmabot.settings import KARMABOT_ID 12 | 13 | USERS = { 14 | "ABC123": "pybob", 15 | "EFG123": "Julian Sequeira", 16 | "XYZ123": "clamytoe", 17 | KARMABOT_ID: "karmabot", 18 | } 19 | SlackResponse = namedtuple("SlackResponse", "status_code, data") 20 | 21 | 22 | @pytest.fixture 23 | def save_transaction_disabled(monkeypatch): 24 | def _disabled(*args): 25 | return 26 | 27 | monkeypatch.setattr("karmabot.karma.Karma._save_transaction", _disabled) 28 | 29 | 30 | @pytest.fixture 31 | def conversations_info_fake_channel(monkeypatch): 32 | def mock_conversation_info(channel): 33 | response = SlackResponse( 34 | status_code=200, data={"channel": {"name": f"{channel}"}} 35 | ) 36 | return response 37 | 38 | monkeypatch.setattr( 39 | "karmabot.bot.app.client.conversations_info", mock_conversation_info 40 | ) 41 | 42 | 43 | @pytest.fixture 44 | def users_profile_get_fake_user(monkeypatch): 45 | def mock_users_profile_get(user): 46 | name = USERS.get(user) 47 | profile = {"display_name_normalized": name, "real_name_normalized": name} 48 | response = SlackResponse(status_code=200, data={"profile": profile}) 49 | return response 50 | 51 | monkeypatch.setattr( 52 | "karmabot.bot.app.client.users_profile_get", mock_users_profile_get 53 | ) 54 | 55 | 56 | @pytest.fixture(scope="session") 57 | def engine(): 58 | return create_engine("sqlite://") 59 | 60 | 61 | @pytest.fixture(scope="session") 62 | def tables(engine): 63 | KarmaUser.metadata.create_all(engine) 64 | KarmaTransaction.metadata.create_all(engine) 65 | KarmaNote.metadata.create_all(engine) 66 | yield 67 | KarmaUser.metadata.drop_all(engine) 68 | KarmaTransaction.metadata.drop_all(engine) 69 | KarmaNote.metadata.drop_all(engine) 70 | 71 | 72 | @pytest.fixture 73 | def karma_users(): 74 | return [ 75 | KarmaUser(user_id="ABC123", username="pybob", karma_points=392), 76 | KarmaUser(user_id="EFG123", username="Julian Sequeira", karma_points=123), 77 | KarmaUser(user_id="XYZ123", username="clamytoe", karma_points=420), 78 | KarmaUser(user_id=KARMABOT_ID, username="karmabot", karma_points=10), 79 | ] 80 | 81 | 82 | @pytest.fixture 83 | def empty_db_session(engine, tables): 84 | """Returns an SQLAlchemy session, and after the tests 85 | tears down everything properly. 86 | """ 87 | connection = engine.connect() 88 | # begin the nested transaction 89 | transaction = connection.begin() 90 | # use the connection with the already started transaction 91 | session = Session(bind=connection) 92 | 93 | yield session 94 | 95 | session.close() 96 | # roll back the broader transaction 97 | transaction.rollback() 98 | # put back the connection to the connection pool 99 | connection.close() 100 | 101 | 102 | @pytest.fixture 103 | def filled_db_session(engine, tables, karma_users): 104 | """Returns an SQLAlchemy session, and after the tests 105 | tears down everything properly. 106 | """ 107 | connection = engine.connect() 108 | # begin the nested transaction 109 | transaction = connection.begin() 110 | # use the connection with the already started transaction 111 | session = Session(bind=connection) 112 | 113 | session.bulk_save_objects(karma_users) 114 | session.commit() 115 | 116 | yield session 117 | 118 | session.close() 119 | # roll back the broader transaction 120 | transaction.rollback() 121 | # put back the connection to the connection pool 122 | connection.close() 123 | 124 | 125 | @pytest.fixture 126 | def mock_filled_db_session(monkeypatch, filled_db_session): 127 | def mock_session_factory(): 128 | return filled_db_session 129 | 130 | monkeypatch.setattr(database, "_SessionFactory", mock_session_factory) 131 | 132 | 133 | @pytest.fixture 134 | def mock_empty_db_session(monkeypatch, empty_db_session): 135 | def mock_session_factory(): 136 | return empty_db_session 137 | 138 | monkeypatch.setattr(database, "_SessionFactory", mock_session_factory) 139 | -------------------------------------------------------------------------------- /tests/test_karma.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import karmabot.bot # noqa 4 | from karmabot.db.database import database 5 | from karmabot.karma import Karma, KarmaUser, _parse_karma_change, process_karma_changes 6 | from karmabot.settings import KARMA_ACTION_PATTERN, KARMABOT_ID 7 | 8 | 9 | # Karma 10 | @pytest.mark.parametrize( 11 | "test_message, expected", 12 | [ 13 | ("<@ABC123> +++", ("<@ABC123>", "++")), 14 | ("Some cool text\nAnother line\n <@ABC123> +++", ("<@ABC123>", "++")), 15 | ("<@FOO42> ++++", ("<@FOO42>", "+++")), 16 | ("First line\n <@BAR789> ++++\n some more text after", ("<@BAR789>", "+++")), 17 | ], 18 | ) 19 | def test_karma_regex(test_message, expected): 20 | karma_changes = KARMA_ACTION_PATTERN.findall(test_message) 21 | user_id, voting = karma_changes[0] 22 | 23 | assert user_id == expected[0] 24 | assert voting == expected[1] 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "test_change, expected", 29 | [(("<@ABC123>", "+++"), ("ABC123", 3)), (("<@XYZ123>", "----"), ("XYZ123", -4))], 30 | ) 31 | def test_parse_karma_change(test_change, expected): 32 | assert _parse_karma_change(test_change) == expected 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "giver, receiver, channel, amount", 37 | [ 38 | ("ABC123", "XYZ123", "CHANNEL42", 2), 39 | ("XYZ123", "ABC123", "CHANNEL42", 5), 40 | ("EFG123", "ABC123", "CHANNEL42", -3), 41 | ], 42 | ) 43 | @pytest.mark.usefixtures("conversations_info_fake_channel", "mock_filled_db_session") 44 | def test_change_karma(giver, receiver, channel, amount): 45 | with database.session_manager() as session: 46 | pre_change_karma = session.get(KarmaUser, receiver).karma_points 47 | 48 | karma = Karma(giver, receiver, channel) 49 | karma.change_karma(amount) 50 | 51 | with database.session_manager() as session: 52 | post_change = session.get(KarmaUser, receiver).karma_points 53 | 54 | assert post_change == (pre_change_karma + amount) 55 | 56 | 57 | @pytest.mark.usefixtures("save_transaction_disabled", "mock_filled_db_session") 58 | def test_change_karma_msg(): 59 | karma = Karma("ABC123", "XYZ123", "CHANNEL42") 60 | assert karma.change_karma(4) == "clamytoe's karma increased to 424" 61 | 62 | karma = Karma("EFG123", "ABC123", "CHANNEL42") 63 | assert karma.change_karma(-3) == "pybob's karma decreased to 389" 64 | 65 | 66 | @pytest.mark.usefixtures("mock_filled_db_session") 67 | def test_change_karma_exceptions(mock_filled_db_session): 68 | with pytest.raises(RuntimeError): 69 | karma = Karma("ABC123", "XYZ123", "CHANNEL42") 70 | karma.change_karma("ABC") 71 | 72 | with pytest.raises(ValueError): 73 | karma = Karma("ABC123", "ABC123", "CHANNEL42") 74 | karma.change_karma(2) 75 | 76 | 77 | @pytest.mark.usefixtures("save_transaction_disabled", "mock_filled_db_session") 78 | def test_change_karma_bot_self(): 79 | karma = Karma("ABC123", KARMABOT_ID, "CHANNEL42") 80 | assert ( 81 | karma.change_karma(2) == "Thanks pybob for the extra karma, my karma is 12 now" 82 | ) 83 | 84 | karma = Karma("EFG123", KARMABOT_ID, "CHANNEL42") 85 | assert ( 86 | karma.change_karma(3) 87 | == "Thanks Julian Sequeira for the extra karma, my karma is 15 now" 88 | ) 89 | 90 | karma = Karma("ABC123", KARMABOT_ID, "CHANNEL42") 91 | assert ( 92 | karma.change_karma(-3) 93 | == "Not cool pybob lowering my karma to 12, but you are probably right, I will work harder next time" 94 | ) 95 | 96 | 97 | @pytest.mark.parametrize( 98 | "karma_giver, channel_id, karma_changes, expected", 99 | [ 100 | ( 101 | "ABC123", 102 | "FOO123", 103 | [("<@EFG123>", "++"), ("<@XYZ123>", "++")], 104 | [ 105 | "Julian Sequeira's karma increased to 125", 106 | "clamytoe's karma increased to 422", 107 | ], 108 | ), 109 | ( 110 | "XYZ123", 111 | "FOO123", 112 | [("<@ABC123>", "+++"), ("<@EFG123>", "++++")], 113 | [ 114 | "pybob's karma increased to 395", 115 | "Julian Sequeira's karma increased to 127", 116 | ], 117 | ), 118 | ], 119 | ) 120 | @pytest.mark.usefixtures("save_transaction_disabled", "mock_filled_db_session") 121 | def test_process_karma_changes(karma_giver, channel_id, karma_changes, expected): 122 | karma_replies = process_karma_changes(karma_giver, channel_id, karma_changes) 123 | 124 | assert karma_replies == expected 125 | -------------------------------------------------------------------------------- /src/karmabot/commands/note.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from typing import Callable, Dict, Tuple, Union 4 | 5 | from sqlalchemy import select 6 | 7 | from karmabot.db.database import database 8 | from karmabot.db.karma_note import KarmaNote 9 | from karmabot.db.karma_user import KarmaUser 10 | 11 | NOTE_CMD_PATTERN = re.compile(r"note\s(\w+)\s?(.*)") 12 | 13 | 14 | def note(user_id: str, channel: str, text: str) -> Union[None, str]: 15 | """Allows the user to store and retrieve simple notes. 16 | 17 | - Syntax for adding a note: @karmabot note add <">my note<"> (note message can be in quotes) 18 | - Syntax for listing notes: @karmabot note list 19 | - Syntax for removing a note: @karmabote note del 1 20 | 21 | Each note is stored for the current user only. A user can only list and delete her own notes. 22 | """ 23 | user_id = user_id.strip("<>@") 24 | 25 | # retrieve current user 26 | with database.session_manager() as session: 27 | user = session.get(KarmaUser, user_id) 28 | 29 | cmd, _ = _parse_note_cmd(text) 30 | 31 | note_cmd_fnc = NOTE_COMMANDS.get(cmd, _command_not_found) 32 | 33 | return note_cmd_fnc(text, user) 34 | 35 | 36 | def _add_note(text: str, user: KarmaUser) -> str: 37 | """Adds a new note to the database for the given user.""" 38 | _, note_msg = _parse_note_cmd(text) 39 | if not note_msg: 40 | return f"Sorry {user.username}, could not find a note in your message." 41 | 42 | if _note_exists(note_msg, user): 43 | return f"Sorry {user.username}, you already have an identical note." 44 | 45 | note = KarmaNote( 46 | user_id=user.user_id, timestamp=datetime.datetime.now(), note=note_msg 47 | ) 48 | 49 | with database.session_manager() as session: 50 | session.add(note) 51 | session.commit() 52 | 53 | return f"Hey {user.username}, you've just stored a note." 54 | 55 | 56 | def _del_note(text: str, user: KarmaUser) -> str: 57 | """Deletes the note with the given note id.""" 58 | _, note_id = _parse_note_cmd(text) 59 | 60 | if not note_id: 61 | return f"Sorry {user.username}, it seems you did not provide a valid id." 62 | 63 | with database.session_manager() as session: 64 | query = session.execute( 65 | select(KarmaNote).filter_by(id=note_id, user_id=user.user_id) 66 | ) 67 | 68 | row_count = query.delete() 69 | session.commit() # otherwise, the deletion is not performed 70 | 71 | if row_count: 72 | return f"Hey {user.username}, your note was successfully deleted." 73 | 74 | return ( 75 | f"Sorry {user.username}, something went wrong, no record was deleted. " 76 | f"Please ask an admin..." 77 | ) 78 | 79 | 80 | def _list_notes(text: str, user: KarmaUser) -> str: 81 | """List all notes for a given user.""" 82 | notes = _get_notes_for_user(user) 83 | 84 | if not notes: 85 | return ( 86 | f"Sorry {user.username}, you don't have any notes so far! " 87 | f"Just start adding notes via the 'note add' command." 88 | ) 89 | 90 | msg = "\n".join(f"{i+1}. note {str(note)}" for i, note in enumerate(notes)) 91 | 92 | return msg 93 | 94 | 95 | def _command_not_found(text: str, user: KarmaUser) -> str: 96 | return ( 97 | f"Sorry {user.username}, your note command was not recognized. " 98 | f"You can use {', '.join(NOTE_COMMANDS.keys())}." 99 | ) 100 | 101 | 102 | def _parse_note_cmd(text: str) -> Tuple[str, str]: 103 | note_cmd = ("", "") 104 | 105 | match = NOTE_CMD_PATTERN.search(text) 106 | if match: 107 | note_cmd = match.group(1).strip(), match.group(2).strip("\"'") 108 | 109 | return note_cmd 110 | 111 | 112 | def _get_notes_for_user(user: KarmaUser) -> list: 113 | with database.session_manager() as session: 114 | notes = session.execute(select(KarmaNote).filter_by(user_id=user.user_id)).all() 115 | return notes 116 | 117 | 118 | def _note_exists(msg: str, user: KarmaUser) -> bool: 119 | with database.session_manager() as session: 120 | statement = select(KarmaNote).filter_by(note=msg, user_id=user.user_id) 121 | db_note = session.execute(statement).first() 122 | 123 | if db_note: 124 | return True 125 | 126 | return False 127 | 128 | 129 | NOTE_COMMANDS: Dict[str, Callable] = { 130 | "add": _add_note, 131 | "del": _del_note, 132 | "list": _list_notes, 133 | } 134 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Nox sessions.""" 2 | import os 3 | import tempfile 4 | from typing import Any 5 | 6 | import nox 7 | from nox.sessions import Session 8 | 9 | package = "karmabot" 10 | locations = "src", "tests", "noxfile.py" 11 | env = { 12 | "KARMABOT_SLACK_USER": "FAKE_BOT_USER", 13 | "KARMABOT_GENERAL_CHANNEL": "FAKE_GENERAL_CHANNEL", 14 | "KARMABOT_LOG_CHANNEL": "FAKE_LOG_CHANNEL", 15 | "KARMABOT_ADMINS": "FAKE_ADMIN1,FAKE_ADMIN2,FAKE_ADMIN3", 16 | "KARMABOT_DATABASE_URL": "FAKE_DB_URL", 17 | "KARMABOT_SLACK_APP_TOKEN": "FAKE_APP_TOKEN", 18 | "KARMABOT_SLACK_BOT_TOKEN": "FAKE_BOT_TOKEN", 19 | "KARMABOT_TEST_MODE": "true", 20 | "SQLALCHEMY_SILENCE_UBER_WARNING": "0", 21 | "SQLALCHEMY_WARN_20": "1", 22 | } 23 | 24 | nox.options.sessions = "tests", "lint", "black", "mypy", "safety" 25 | 26 | 27 | def install_with_constraints(session: Session, *args: str, **kwargs: Any) -> None: 28 | """Install packages constrained by Poetry's lock file. 29 | 30 | This function is a wrapper for nox.sessions.Session.install. It 31 | invokes pip to install packages inside of the session's virtualenv. 32 | Additionally, pip is passed a constraints file generated from 33 | Poetry's lock file, to ensure that the packages are pinned to the 34 | versions specified in poetry.lock. This allows you to manage the 35 | packages as Poetry development dependencies. 36 | 37 | Arguments: 38 | session: The Session object. 39 | args: Command-line arguments for pip. 40 | kwargs: Additional keyword arguments for Session.install. 41 | 42 | """ 43 | 44 | with tempfile.NamedTemporaryFile(delete=False) as requirements: 45 | session.run( 46 | "poetry", 47 | "export", 48 | "--format=requirements.txt", 49 | "--without-hashes", 50 | f"--output={requirements.name}", 51 | external=True, 52 | ) 53 | session.install(f"--constraint={requirements.name}", *args, **kwargs) 54 | 55 | requirements.close() 56 | os.unlink(requirements.name) 57 | 58 | 59 | @nox.session(python=["3.10", "3.11"]) 60 | def tests(session: Session) -> None: 61 | """Run the test suite.""" 62 | args = session.posargs or ["--cov", "-m", "not e2e"] 63 | session.run("poetry", "install", "--only", "main", external=True) 64 | install_with_constraints(session, "coverage", "pytest", "pytest-cov", "pytest-mock") 65 | session.run("pytest", *args, env=env) 66 | 67 | 68 | @nox.session(python="3.10") 69 | def lint(session: Session) -> None: 70 | """Lint using flake8.""" 71 | args = session.posargs or locations 72 | install_with_constraints( 73 | session, 74 | "flake8", 75 | "flake8-bandit", 76 | "flake8-bugbear", 77 | "flake8-blind-except", 78 | "flake8-builtins", 79 | "flake8-logging-format", 80 | "flake8-debugger", 81 | "flake8-use-fstring", 82 | ) 83 | session.run("flake8", *args) 84 | 85 | 86 | @nox.session(python="3.10") 87 | def black(session: Session) -> None: 88 | """Run black code formatter.""" 89 | args = session.posargs or locations 90 | install_with_constraints(session, "black") 91 | session.run("black", *args) 92 | 93 | 94 | @nox.session(python="3.10") 95 | def mypy(session: Session) -> None: 96 | """Type-check using mypy.""" 97 | args = session.posargs or locations 98 | install_with_constraints(session, "mypy") 99 | session.run("mypy", *args) 100 | 101 | 102 | @nox.session(python=["3.10", "3.11"]) 103 | def safety(session: Session) -> None: 104 | """Scan dependencies for insecure packages.""" 105 | with tempfile.NamedTemporaryFile(delete=False) as requirements: 106 | session.run( 107 | "poetry", 108 | "export", 109 | "--with", 110 | "dev", 111 | "--format=requirements.txt", 112 | "--without-hashes", 113 | f"--output={requirements.name}", 114 | external=True, 115 | ) 116 | install_with_constraints(session, "safety") 117 | session.run( 118 | "safety", 119 | "check", 120 | f"--file={requirements.name}", 121 | "--full-report", 122 | "--ignore=41002", 123 | ) 124 | 125 | requirements.close() 126 | os.unlink(requirements.name) 127 | 128 | 129 | @nox.session(python="3.10") 130 | def coverage(session: Session) -> None: 131 | """Upload coverage data.""" 132 | install_with_constraints(session, "coverage[toml]") 133 | session.run("coverage", "report", "--fail-under=40") 134 | session.run("coverage", "xml", "--fail-under=40") 135 | -------------------------------------------------------------------------------- /src/karmabot/karma.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy import select 4 | 5 | import karmabot.bot as bot 6 | import karmabot.slack as slack 7 | from karmabot.db.database import database 8 | from karmabot.db.karma_transaction import KarmaTransaction 9 | from karmabot.db.karma_user import KarmaUser 10 | from karmabot.exceptions import GetUserInfoException 11 | from karmabot.settings import KARMABOT_ID, MAX_POINTS 12 | 13 | 14 | class Karma: 15 | def __init__(self, giver_id, receiver_id, channel_id): 16 | self.session = database.session 17 | self.giver: KarmaUser = self.session.get(KarmaUser, giver_id) 18 | self.receiver: KarmaUser = self.session.get(KarmaUser, receiver_id) 19 | 20 | if not self.giver: 21 | self.giver = self._create_karma_user(giver_id) 22 | if not self.receiver: 23 | self.receiver = self._create_karma_user(receiver_id) 24 | 25 | self.channel_id: str = channel_id 26 | self.last_score_maxed_out: bool = False 27 | 28 | def _create_karma_user(self, user_id): 29 | response = bot.app.client.users_profile_get(user=user_id) 30 | status = response.status_code 31 | 32 | if status != 200: 33 | logging.info("Cannot get user info for %s - API error: %s", user_id, status) 34 | raise GetUserInfoException 35 | 36 | user_profile = response.data["profile"] 37 | username = slack.get_available_username(user_profile) 38 | 39 | new_user = KarmaUser(user_id=user_id, username=username) 40 | self.session.add(new_user) 41 | self.session.commit() 42 | 43 | logging.info("Created new KarmaUser: %s", repr(new_user)) 44 | return new_user 45 | 46 | def _calc_final_score(self, points): 47 | if abs(points) > MAX_POINTS: 48 | self.last_score_maxed_out = True 49 | return MAX_POINTS if points > 0 else -MAX_POINTS 50 | else: 51 | self.last_score_maxed_out = False 52 | return points 53 | 54 | def _create_msg_bot_self_karma(self, points) -> str: 55 | if points > 0: 56 | text = ( 57 | f"Thanks {self.giver.username} for the extra karma" 58 | f", my karma is {self.receiver.karma_points} now" 59 | ) 60 | else: 61 | text = ( 62 | f"Not cool {self.giver.username} lowering my karma " 63 | f"to {self.receiver.karma_points}, but you are probably" 64 | f" right, I will work harder next time" 65 | ) 66 | return text 67 | 68 | def _create_msg(self, points): 69 | receiver_name = self.receiver.username 70 | 71 | poses = "'" if receiver_name.endswith("s") else "'s" 72 | action = "increase" if points > 0 else "decrease" 73 | 74 | text = ( 75 | f"{receiver_name}{poses} karma {action}d to " 76 | f"{self.receiver.karma_points}" 77 | ) 78 | if self.last_score_maxed_out: 79 | text += f" (= max {action} of {MAX_POINTS})" 80 | 81 | return text 82 | 83 | def _save_transaction(self, points): 84 | response = bot.app.client.conversations_info(channel=self.channel_id) 85 | 86 | if response.status_code != 200: 87 | raise Exception( 88 | f"Slack API could not get Channel info - Status {response.status_code}" 89 | ) 90 | 91 | channel_name = response.data["channel"]["name"] 92 | 93 | transaction = KarmaTransaction( 94 | giver_id=self.giver.user_id, 95 | receiver_id=self.receiver.user_id, 96 | channel=channel_name, 97 | karma=points, 98 | ) 99 | 100 | self.session.add(transaction) 101 | self.session.commit() 102 | 103 | finished_transaction = ( 104 | self.session.execute( 105 | select(KarmaTransaction).order_by(KarmaTransaction.id.desc()) 106 | ).first() 107 | # SQLAlchemy migration 108 | # self.session.query(KarmaTransaction) 109 | # .order_by(KarmaTransaction.id.desc()) 110 | # .first() 111 | ) 112 | logging.info(repr(finished_transaction)) 113 | 114 | def change_karma(self, points): 115 | """Updates Karma in the database""" 116 | if not isinstance(points, int): 117 | err = "change_karma should not be called with a non int points arg!" 118 | raise RuntimeError(err) 119 | 120 | try: 121 | if self.receiver.user_id == self.giver.user_id: 122 | raise ValueError("Sorry, cannot give karma to self") 123 | 124 | points = self._calc_final_score(points) 125 | self.receiver.karma_points += points 126 | self.session.commit() 127 | 128 | self._save_transaction(points) 129 | 130 | if self.receiver.user_id == KARMABOT_ID: 131 | return self._create_msg_bot_self_karma(points) 132 | else: 133 | return self._create_msg(points) 134 | 135 | finally: 136 | logging.info( 137 | "[Karmachange] %s to %s: %s", 138 | self.giver.user_id, 139 | self.receiver.user_id, 140 | points, 141 | ) 142 | self.session.close() 143 | 144 | 145 | def _parse_karma_change(karma_change): 146 | user_id, voting = karma_change 147 | 148 | receiver = slack.get_user_id(user_id) 149 | points = voting.count("+") - voting.count("-") 150 | 151 | return receiver, points 152 | 153 | 154 | def process_karma_changes(karma_giver, channel_id, karma_changes): 155 | messages = [] 156 | for karma_change in karma_changes: 157 | receiver_id, points = _parse_karma_change(karma_change) 158 | try: 159 | karma = Karma( 160 | giver_id=karma_giver, 161 | receiver_id=receiver_id, 162 | channel_id=channel_id, 163 | ) 164 | except GetUserInfoException: 165 | return "Sorry, something went wrong while retrieving user information" 166 | 167 | try: 168 | text = karma.change_karma(points) 169 | except (RuntimeError, ValueError) as exc: 170 | text = str(exc) 171 | 172 | messages.append(text) 173 | 174 | return messages 175 | -------------------------------------------------------------------------------- /src/karmabot/commands/topchannels.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import namedtuple 3 | from datetime import datetime as dt 4 | from math import exp 5 | from operator import itemgetter 6 | from typing import Dict, List, Optional, Union 7 | 8 | import humanize 9 | from slack_sdk.errors import SlackApiError 10 | 11 | import karmabot.bot as bot 12 | from karmabot.settings import KARMABOT_ID 13 | 14 | MSG_BEGIN = "Glad you asked, here are some channels our Community recommends (based on member count and activity):\n" 15 | MSG_LINE = ( 16 | "- #{channel} ({member_count} members, last post {time_since_last_post}): {purpose}" 17 | ) 18 | DEFAULT_NR_CHANNELS = 7 19 | 20 | Channel = namedtuple("Channel", "id name purpose num_members latest_ts latest_subtype") 21 | 22 | 23 | def channel_is_potential(channel): 24 | is_channel = channel["is_channel"] 25 | is_member = channel["is_member"] 26 | is_general = channel["is_general"] 27 | is_private = channel["is_private"] 28 | return is_channel and is_member and not is_general and not is_private 29 | 30 | 31 | def collect_channel_info(channel): 32 | channel_id = channel["id"] 33 | try: 34 | info_response: Dict = bot.app.client.conversations_info( 35 | channel=channel_id, include_num_members=True 36 | ) 37 | if not info_response["ok"]: 38 | raise SlackApiError("converstation.info error", info_response) 39 | 40 | history_response: Dict = bot.app.client.conversations_history( 41 | channel=channel_id, limit=1 42 | ) 43 | if not history_response["ok"]: 44 | raise SlackApiError("conversation.history error", history_response) 45 | 46 | except SlackApiError: 47 | logging.exception("Slack error") 48 | return "I am truly sorry but something went wrong ;(" 49 | 50 | channel_info: Dict = info_response["channel"] 51 | channel_history: Dict = history_response["messages"][0] 52 | 53 | latest_ts = channel_history.get("ts") 54 | latest_type = channel_history.get("type") 55 | if latest_ts: 56 | info = Channel( 57 | channel["id"], 58 | channel["name"], 59 | channel_info["purpose"]["value"], 60 | channel_info["num_members"], 61 | float(latest_ts), 62 | latest_type, 63 | ) 64 | return info 65 | 66 | return None 67 | 68 | 69 | def get_recommended_channels(**kwargs): 70 | """Show some of our Community's favorite channels you can join""" 71 | text = kwargs.get("text") 72 | 73 | potential_channels: Channel = [] 74 | msg = MSG_BEGIN 75 | 76 | if not text: 77 | nr_channels = DEFAULT_NR_CHANNELS 78 | else: 79 | nr_channels = text.split()[2] if len(text.split()) >= 3 else DEFAULT_NR_CHANNELS 80 | 81 | if isinstance(nr_channels, str): 82 | nr_channels = ( 83 | int(nr_channels) if nr_channels.isnumeric() else DEFAULT_NR_CHANNELS 84 | ) 85 | 86 | # retrieve channel list 87 | response: Dict = bot.app.client.conversations_list( 88 | exclude_archived=True, types="public_channel" 89 | ) 90 | 91 | if not response["ok"]: 92 | logging.error('Error for API call "channels.list": %s', response["error"]) 93 | return "I am truly sorry but something went wrong ;(" 94 | 95 | channels: List[Dict] = response["channels"] 96 | 97 | # retrieve channel info for each channel in channel list 98 | # only consider channels that are not the general channel, that are not private and that have at least one message 99 | for channel in channels: 100 | if channel_is_potential(channel): 101 | info = collect_channel_info(channel) 102 | if info: 103 | potential_channels.append(info) 104 | 105 | # now weight channels and return message 106 | potential_channels = sorted( 107 | ((calc_channel_score(chan), chan) for chan in potential_channels), reverse=True 108 | ) 109 | 110 | msg = MSG_BEGIN + "\n".join( 111 | ( 112 | MSG_LINE.format( 113 | channel=channel.name, 114 | member_count=channel.num_members, 115 | time_since_last_post=humanize.naturaltime( 116 | seconds_since_last_post(channel) 117 | ), 118 | purpose=channel.purpose 119 | or "", 120 | ) 121 | for score, channel in potential_channels[:nr_channels] 122 | if score > 0 123 | ) 124 | ) 125 | 126 | return msg 127 | 128 | 129 | def get_messages( 130 | channel: Channel, ignore_message_types: Union[set, None] = None 131 | ) -> Union[List[Dict], None]: 132 | """Return a list of the most recent messages in a given channel, filtering 133 | out certain message types. 134 | 135 | Similar to invite permissions, this requires a user token. 136 | 137 | "New" bot tokens will be both more granular and more flexible, so should 138 | be able to replace the need for user tokens going forward: 139 | 140 | Ref: https://api.slack.com/docs/token-types#bot_new 141 | """ 142 | if ignore_message_types is None: 143 | ignore_message_types = {"channel_join"} 144 | 145 | response = bot.app.client.conversations_history( 146 | channel=channel.id, user=KARMABOT_ID 147 | ) 148 | 149 | return response["messages"] 150 | 151 | 152 | def calc_channel_score(channel: Channel): 153 | """simple calculation of a channels value 154 | the higher the number of members and the less the number of seconds since the last post the higher the channels score 155 | """ 156 | since_latest = seconds_since_last_post(channel) 157 | if not since_latest: 158 | return 0 159 | num_members = channel.num_members 160 | time_delta_in_hours = since_latest / 3600 161 | 162 | return num_members * (exp(-time_delta_in_hours)) 163 | 164 | 165 | def seconds_since_last_post(channel: Channel) -> Optional[float]: 166 | """return the fraction of days since the last post in a channel, or None if 167 | all messages are of filtered/ignored subtypes 168 | """ 169 | 170 | ignore_message_types = {"channel_join"} 171 | 172 | if channel.latest_subtype not in ignore_message_types: 173 | latest_ts = channel.latest_ts 174 | else: 175 | msgs = get_messages(channel, ignore_message_types) 176 | if not msgs: 177 | return None 178 | latest_ts = float(max(msgs, key=itemgetter("ts"))["ts"]) 179 | 180 | return (dt.now() - dt.fromtimestamp(latest_ts)).total_seconds() 181 | -------------------------------------------------------------------------------- /src/karmabot/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import unicodedata 4 | from typing import Callable, Dict 5 | 6 | from slack_bolt import App 7 | 8 | # Commands 9 | from karmabot.commands.add import add_command 10 | from karmabot.commands.age import pybites_age 11 | from karmabot.commands.control import general_channel_id, join_public_channels, your_id 12 | from karmabot.commands.doc import doc_command 13 | from karmabot.commands.feed import get_pybites_last_entries 14 | from karmabot.commands.joke import joke 15 | from karmabot.commands.note import note 16 | from karmabot.commands.score import get_karma, top_karma 17 | from karmabot.commands.tip import get_random_tip 18 | from karmabot.commands.topchannels import get_recommended_channels 19 | from karmabot.commands.update_username import get_user_name, update_username 20 | from karmabot.commands.welcome import welcome_user 21 | from karmabot.commands.zen import import_this 22 | from karmabot.exceptions import CommandExecutionException 23 | from karmabot.karma import process_karma_changes 24 | 25 | # Settings 26 | from karmabot.settings import ( 27 | ADMINS, 28 | KARMA_ACTION_PATTERN, 29 | KARMABOT_ID, 30 | LOG_CHANNEL, 31 | SLACK_BOT_TOKEN, 32 | TEST_MODE, 33 | ) 34 | from karmabot.slack import MessageChannelType 35 | 36 | # command constants 37 | ADMIN_BOT_COMMANDS = { 38 | "top_karma": top_karma, 39 | "join_public_channels": join_public_channels, 40 | "your_id": your_id, 41 | "general_channel_id": general_channel_id, 42 | } 43 | CHANNEL_BOT_COMMANDS = { 44 | "add": add_command, 45 | "age": pybites_age, 46 | "joke": joke, 47 | "note": note, 48 | "tip": get_random_tip, 49 | "topchannels": get_recommended_channels, 50 | "zen": import_this, 51 | } 52 | DM_BOT_COMMANDS = { 53 | "doc": doc_command, 54 | "feed": get_pybites_last_entries, 55 | "joke": joke, 56 | "note": note, 57 | "karma": get_karma, 58 | "topchannels": get_recommended_channels, 59 | "username": get_user_name, 60 | "updateusername": update_username, 61 | } 62 | 63 | 64 | def compile_command_pattern(commands: Dict[str, Callable]) -> re.Pattern: 65 | command_words = commands.keys() 66 | all_commands = "|".join(command_words) 67 | 68 | full_commands = rf"^<@{KARMABOT_ID}>\s*({all_commands})(\s.*)?$" 69 | 70 | return re.compile(full_commands, re.IGNORECASE) 71 | 72 | 73 | ADMIN_COMMAND_PATTERN = compile_command_pattern(ADMIN_BOT_COMMANDS) 74 | CHANNEL_COMMAND_PATTERN = compile_command_pattern(CHANNEL_BOT_COMMANDS) # type: ignore 75 | DM_COMMAND_PATTERN = compile_command_pattern(DM_BOT_COMMANDS) # type: ignore 76 | UNKNOWN_COMMAND_PATTERN = re.compile(rf"^<@{KARMABOT_ID}>\s(\w*)") 77 | HELP_COMMAND_PATTERN = re.compile(rf"^<@{KARMABOT_ID}>\s(help|commands)") 78 | COMMAND_ERROR = "Sorry, something went wrong when performing the requested command" 79 | 80 | # Slack Bolt App Init 81 | # For testing we disable the token_verification, such that no valied token is required 82 | TOKEN_VERIFICATION = not TEST_MODE 83 | app = App( 84 | token=SLACK_BOT_TOKEN, 85 | name="Karmabot", 86 | token_verification_enabled=TOKEN_VERIFICATION, 87 | ) # type: ignore 88 | 89 | 90 | # Helpers 91 | def perform_command(commands, cmd_match, params): 92 | cmd = cmd_match.group(1) 93 | try: 94 | return commands[cmd](**params) 95 | except KeyError: 96 | return f"No command '{cmd}' found" 97 | 98 | 99 | def create_commands_table(commands): 100 | """Print this help text""" 101 | ret = "\n".join( 102 | [ 103 | f"{name:<30}: {func.__doc__.strip()}" 104 | for name, func in sorted(commands.items()) 105 | ] 106 | ) 107 | return f"```{ret}```" 108 | 109 | 110 | # Top priority: process karma 111 | @app.message(KARMA_ACTION_PATTERN) # type: ignore 112 | def karma_action(message, say): 113 | msg = unicodedata.normalize("NFKD", message["text"]) 114 | 115 | karma_giver = message["user"] 116 | channel_id = message["channel"] 117 | karma_changes = KARMA_ACTION_PATTERN.findall(msg) 118 | 119 | karma_replies = process_karma_changes(karma_giver, channel_id, karma_changes) 120 | for reply in karma_replies: 121 | say(reply, channel=LOG_CHANNEL) 122 | 123 | 124 | # Help 125 | @app.message(HELP_COMMAND_PATTERN) # type: ignore 126 | def reply_help(message, say): 127 | """Sends the list of available commands as DM to the user""" 128 | user_id = message["user"] 129 | channel_type = message["channel_type"] 130 | 131 | help_msg = [ 132 | "\n1. Channel commands (format: `@karmabot command`)", 133 | create_commands_table(CHANNEL_BOT_COMMANDS), 134 | "\n2. Message commands (type `@karmabot command` in a DM to the bot)", 135 | create_commands_table(DM_BOT_COMMANDS), 136 | ] 137 | 138 | if user_id in ADMINS and channel_type == MessageChannelType.DM.value: 139 | help_msg.append("\n3. Admin only commands") 140 | help_msg.append(create_commands_table(ADMIN_BOT_COMMANDS)) 141 | 142 | text = "\n".join(help_msg) 143 | say(text=text, channel=user_id) 144 | 145 | 146 | # Commands 147 | @app.event("message") # type: ignore 148 | def reply_commands(message, say): # noqa 149 | """ 150 | Handles all the commands in one place 151 | 152 | Unfortunatly we cannot create sperate functions for every category 153 | (admin, private public) as some commands fit in multiple catagories and the 154 | first matching function would "swallow" the message and not forwared 155 | it further down the line 156 | """ 157 | try: 158 | user_id = message["user"] 159 | channel_id = message["channel"] 160 | text = message["text"] 161 | channel_type = message["channel_type"] 162 | except KeyError: 163 | logging.exception("reply_commands error! Message was: %s", message) 164 | return 165 | 166 | kwargs = {"user_id": user_id, "channel": channel_id, "text": text} 167 | cmd_result = None 168 | 169 | admin_match = ADMIN_COMMAND_PATTERN.match(text) 170 | if ( 171 | admin_match 172 | and channel_type == MessageChannelType.DM.value 173 | and user_id in ADMINS 174 | ): 175 | cmd_result = perform_command(ADMIN_BOT_COMMANDS, admin_match, kwargs) 176 | if not cmd_result: 177 | say(COMMAND_ERROR) 178 | raise CommandExecutionException(text) 179 | 180 | private_match = DM_COMMAND_PATTERN.match(text) 181 | if private_match and channel_type == MessageChannelType.DM.value: 182 | cmd_result = perform_command(DM_BOT_COMMANDS, private_match, kwargs) 183 | if not cmd_result: 184 | say(COMMAND_ERROR) 185 | raise CommandExecutionException(text) 186 | 187 | public_match = CHANNEL_COMMAND_PATTERN.match(text) 188 | if public_match and channel_type in [ 189 | MessageChannelType.CHANNEL.value, 190 | MessageChannelType.GROUP.value, 191 | ]: 192 | cmd_result = perform_command(CHANNEL_BOT_COMMANDS, public_match, kwargs) 193 | if not cmd_result: 194 | say(COMMAND_ERROR) 195 | raise CommandExecutionException(text) 196 | 197 | if cmd_result: # reply with result to a valid cmd 198 | say(cmd_result) 199 | elif UNKNOWN_COMMAND_PATTERN.match(text): # everything else that looks like a cmd 200 | unknown_cmd = UNKNOWN_COMMAND_PATTERN.findall(text)[0] 201 | say(f'Sorry <@{user_id}>, there is no command "{unknown_cmd}"') 202 | 203 | # all other messages just do not get a reply 204 | 205 | 206 | # Events 207 | @app.event("team_join") # type: ignore 208 | def welcome_new_user(event, say): 209 | user_id = event["user"]["id"] 210 | text = welcome_user(user_id) 211 | logging.info("Sending welcome DM to new member %s", user_id) 212 | say(text=text, channel=user_id) 213 | 214 | 215 | @app.event("channel_created") # type: ignore 216 | def autojoin_new_channels(event, say): 217 | new_channel_id = event["channel"]["id"] 218 | app.client.conversations_join(channel=new_channel_id) 219 | 220 | text = ( 221 | "Awesome, a new PyBites channel! Birds of a feather flock together! " 222 | "Keep doing your nerdy stuff, I will keep track of your karmas :)" 223 | ) 224 | say(text=text, channel=new_channel_id) 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyBites Karmabot - A Python based Slack Chatbot 2 | 3 | [![Tests](https://github.com/PyBites-Open-Source/karmabot/workflows/Tests/badge.svg)](https://github.com/PyBites-Open-Source/karmabot/actions?workflow=Tests) [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) [![codecov.io](https://codecov.io/github/PyBites-Open-Source/karmabot/coverage.svg?branch=master)](https://codecov.io/github/PyBites-Open-Source/karmabot?branch=master) 4 | 5 | **A Python based Slack Chatbot for Community interaction** 6 | 7 | ## Features 8 | 9 | Karmabot's main features is the management of Karma within the slack community server. You can give karma, reduce karma, check your current karma points and manage your karma related username. 10 | 11 | ![karma example](https://www.pogross.de/uploads/karmabot.png) 12 | 13 | [Demo Youtube Video](https://www.youtube.com/watch?v=Yx9qYl6lmzM&t=2s) 14 | 15 | Additional commands / features are: 16 | 17 | - Jokes powered by [PyJokes](https://github.com/pyjokes/pyjokes) 18 | - Overview on top channels of the slack server 19 | - Random Python tip, quote or nugget from CodeChalleng.es 20 | - Browse and search python documentation, "pydoc help" 21 | 22 | ## Installation 23 | 24 | `pip install karmabot` 25 | 26 | ## Basic Usage 27 | 28 | After installing you can start karmabot by using the command 29 | 30 | ```bash 31 | karmabot 32 | ``` 33 | 34 | However, you need to some setup and supply some settings prior to this. 35 | 36 | ### Setup 37 | 38 | For app creation and tokens please follow the [slack-bolt guide](https://slack.dev/bolt-python/tutorial/getting-started) and enable [socket mode](https://slack.dev/bolt-python/concepts#socket-mode). 39 | 40 | #### Settings 41 | 42 | By default we will look for a `.karmabot` file in the directory you used the `karmabot` command. The file should supply the following information. 43 | 44 | ```env 45 | # Slack bot app 46 | KARMABOT_SLACK_BOT_TOKEN= 47 | KARMABOT_SLACK_APP_TOKEN= 48 | 49 | # Workspace 50 | KARMABOT_SLACK_USER= 51 | KARMABOT_GENERAL_CHANNEL= 52 | KARMABOT_LOG_CHANNEL= 53 | KARMABOT_ADMINS= 54 | 55 | # Backend 56 | KARMABOT_DATABASE_URL= 57 | 58 | # Testing 59 | KARMABOT_TEST_MODE= 60 | ``` 61 | 62 | KARMABOT_SLACK_BOT_TOKEN 63 | : The [SLACK_BOT_TOKEN](https://slack.dev/bolt-python/tutorial/getting-started) for your bot. You will find it under **OAuth & Permission 🠊 Bot User OAuth Access Token** in your [app](https://api.slack.com/apps/). The token starts with `xoxb-`. 64 | 65 | KARMABOT_SLACK_APP_TOKEN 66 | : The SLACK_APP_TOKEN used for running the bot in [Socket Mode](https://slack.dev/bolt-python/concepts#socket-mode). You will find it under **Basic Information 🠊 App-Level Tokens** in your [app](https://api.slack.com/apps/). 67 | The token starts with `xapp-`. 68 | 69 | KARMABOT_SLACK_USER 70 | : The bot's user id. Initially, you can fill in a placeholder. Once you've run your own Karmabot for the first time, you can ask it as admin in private chat via `@Karmabot your_id`. This will return a value starting with `U`, e.g., `U0123XYZ`. Replace your placeholder with this value. 71 | 72 | KARMABOT_GENERAL_CHANNEL 73 | : The channel id of your main channel in slack. Initially, you can fill in a placeholder. Once you've run your own Karmabot for the first time, you can ask it as admin in private chat via `@Karmabot general_channel_id`. This will return a value starting with `C`, e.g., `C0123XYZ`. Replace your placeholder with this value. 74 | 75 | KARMABOT_LOG_CHANNEL 76 | : The channel id (Cxyz) of the channel the bot logs karma point changes to (e.g. "bobtester2's karma increased to 9") 77 | 78 | KARMABOT_ADMINS 79 | : The [slack user ids](https://api.slack.com/methods/users.identity) of the users that should have admin command access separated by commas. 80 | 81 | KARMABOT_DATABASE_URL 82 | : The database url which should be compatible with SqlAlchemy. For the provided docker file use `postgresql://user42:pw42@localhost:5432/karmabot`. 83 | :heavy_exclamation_mark: To start the provided Docker-based Postgres server, be sure you have Docker Compose [installed](https://docs.docker.com/compose/install/) and run `docker-compose up -d` from the karmabot directory. 84 | 85 | KARMABOT_TEST_MODE= 86 | : Determines if the code is run in test mode. User `KARMABOT_TEST_MODE=true` to enable testing mode. Everything else will default to `false`. This setting has to be provided as `true`, if you want run tests without a valid `KARMABOT_SLACK_BOT_TOKEN`. Otherwise, you will receive an exceptions with `slack_bolt.error.BoltError: token is invalid ...`. 87 | 88 | If you do not want to use a file you have to provide environment variables with the above names. If no file is present we default to environment variables. 89 | 90 | #### Permissions 91 | 92 | Go to your [slack app](https://api.slack.com/apps/) and click on **Add features and functionality**. Then go into the following categories and set permissions. 93 | 94 | - Event Subscriptions 95 | - Enable Events 🠊 Toggle the slider to on 96 | - Subscribe to bot events 🠊 Add via the **Add Bot User Event** button 97 | - team_join 98 | - channel_create 99 | - message.channels 100 | - message.groups 101 | - message.im 102 | - Permissions 103 | - Scopes 🠊 Add the following permissions via the **Add an OAuth Scope** button 104 | - app_mentions:read 105 | - channels:history 106 | - channels:join 107 | - channels:read 108 | - chat:write 109 | - groups:history 110 | - groups:read 111 | - groups:write 112 | - im:history 113 | - im:read 114 | - im:write 115 | - users.profile:read 116 | - users:read 117 | 118 | ## Development pattern for contributors 119 | 120 | We use [poetry](https://github.com/python-poetry/poetry) and `pyproject.toml` for managing packages, dependencies and some settings. 121 | 122 | ### Setup virtual environment for development 123 | 124 | You should follow the [instructions](https://github.com/python-poetry/poetry) to get poetry up and running for your system. We recommend to use a UNIX-based development system (Linux, Mac, WSL). After setting up poetry you can use `poetry install` within the project folder to install all dependencies. 125 | 126 | The poetry virtual environment should be available in the the project folder as `.venv` folder as specified in `poetry.toml`. This helps with `.venv` detection in IDEs. 127 | 128 | #### Conda users 129 | 130 | If you use the Anaconda Python distribution (strongly recommended for Windows users) and `conda create` for your virtual environments, then you will not be able to use the `.venv` environment created by poetry because it is not a conda environment. If you want to use `poetry` disable poetry's behavior of creating a new virtual environment with the following command: `poetry config virtualenvs.create false`. You can add `--local` if you don't want to change this setting globally but only for the current project. See the [poetry configuration docs](https://python-poetry.org/docs/configuration/) for more details. 131 | 132 | Now, when you run `poetry install`, poetry will install all dependencies to your conda environment. You can verify this by running `pip freeze` after `poetry install`. 133 | 134 | ### Testing and linting 135 | 136 | For testing you need to install [nox](https://nox.thea.codes/en/stable/) separately from the project venv created by poetry. For testing just use the `nox` command within the project folder. You can run all the nox sessions separately if need, e.g., 137 | 138 | - only linting `nox -rs lint` 139 | - only testing `nox -rs tests` 140 | 141 | If `nox` cannot be found, use `python -m nox` instead. 142 | 143 | For different sessions see the `nox.py` file. You can run `nox --list` to see a list of all available sessions. 144 | 145 | If you want to run tests locally via `pytest` you have to provide a valid `.karmabot` settings file or the respective enviroment variables. 146 | 147 | Please make sure all tests and checks pass before opening pull requests! 148 | 149 | #### Using nox under Windows and Linux (WSL) 150 | 151 | Make sure to delete the `.nox` folder when you switch from Windows to WSL and vice versa, because the environments are not compatible. 152 | 153 | ### [pre-commit](https://pre-commit.com/) 154 | 155 | To ensure consistency you can use pre-commit. `pip install pre-commit` and after cloning the karmabot repo run `pre-commit install` within the project folder. 156 | 157 | This will enable pre-commit hooks for checking before every commit. 158 | 159 | ### The story behind Karmabot 160 | 161 | Listen to Karmabot's core developer / maintainer Patrick Groß sharing the backstory of this project [on our podcast](https://www.pybitespodcast.com/1501156/8317703-022-the-karmabot-story-and-contributing-to-open-source). 162 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "argcomplete" 5 | version = "2.0.0" 6 | description = "Bash tab completion for argparse" 7 | optional = false 8 | python-versions = ">=3.6" 9 | files = [ 10 | {file = "argcomplete-2.0.0-py2.py3-none-any.whl", hash = "sha256:cffa11ea77999bb0dd27bb25ff6dc142a6796142f68d45b1a26b11f58724561e"}, 11 | {file = "argcomplete-2.0.0.tar.gz", hash = "sha256:6372ad78c89d662035101418ae253668445b391755cfe94ea52f1b9d22425b20"}, 12 | ] 13 | 14 | [package.extras] 15 | test = ["coverage", "flake8", "pexpect", "wheel"] 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "22.2.0" 20 | description = "Classes Without Boilerplate" 21 | optional = false 22 | python-versions = ">=3.6" 23 | files = [ 24 | {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, 25 | {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, 26 | ] 27 | 28 | [package.extras] 29 | cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] 30 | dev = ["attrs[docs,tests]"] 31 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] 32 | tests = ["attrs[tests-no-zope]", "zope.interface"] 33 | tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] 34 | 35 | [[package]] 36 | name = "bandit" 37 | version = "1.7.4" 38 | description = "Security oriented static analyser for python code." 39 | optional = false 40 | python-versions = ">=3.7" 41 | files = [ 42 | {file = "bandit-1.7.4-py3-none-any.whl", hash = "sha256:412d3f259dab4077d0e7f0c11f50f650cc7d10db905d98f6520a95a18049658a"}, 43 | {file = "bandit-1.7.4.tar.gz", hash = "sha256:2d63a8c573417bae338962d4b9b06fbc6080f74ecd955a092849e1e65c717bd2"}, 44 | ] 45 | 46 | [package.dependencies] 47 | colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} 48 | GitPython = ">=1.0.1" 49 | PyYAML = ">=5.3.1" 50 | stevedore = ">=1.20.0" 51 | 52 | [package.extras] 53 | test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] 54 | toml = ["toml"] 55 | yaml = ["PyYAML"] 56 | 57 | [[package]] 58 | name = "black" 59 | version = "24.3.0" 60 | description = "The uncompromising code formatter." 61 | optional = false 62 | python-versions = ">=3.8" 63 | files = [ 64 | {file = "black-24.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395"}, 65 | {file = "black-24.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995"}, 66 | {file = "black-24.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"}, 67 | {file = "black-24.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0"}, 68 | {file = "black-24.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9"}, 69 | {file = "black-24.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597"}, 70 | {file = "black-24.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d"}, 71 | {file = "black-24.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5"}, 72 | {file = "black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f"}, 73 | {file = "black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11"}, 74 | {file = "black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4"}, 75 | {file = "black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5"}, 76 | {file = "black-24.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837"}, 77 | {file = "black-24.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd"}, 78 | {file = "black-24.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213"}, 79 | {file = "black-24.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959"}, 80 | {file = "black-24.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb"}, 81 | {file = "black-24.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7"}, 82 | {file = "black-24.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7"}, 83 | {file = "black-24.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f"}, 84 | {file = "black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93"}, 85 | {file = "black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f"}, 86 | ] 87 | 88 | [package.dependencies] 89 | click = ">=8.0.0" 90 | mypy-extensions = ">=0.4.3" 91 | packaging = ">=22.0" 92 | pathspec = ">=0.9.0" 93 | platformdirs = ">=2" 94 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 95 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 96 | 97 | [package.extras] 98 | colorama = ["colorama (>=0.4.3)"] 99 | d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] 100 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 101 | uvloop = ["uvloop (>=0.15.2)"] 102 | 103 | [[package]] 104 | name = "certifi" 105 | version = "2024.7.4" 106 | description = "Python package for providing Mozilla's CA Bundle." 107 | optional = false 108 | python-versions = ">=3.6" 109 | files = [ 110 | {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, 111 | {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, 112 | ] 113 | 114 | [[package]] 115 | name = "cfgv" 116 | version = "3.3.1" 117 | description = "Validate configuration and produce human readable error messages." 118 | optional = false 119 | python-versions = ">=3.6.1" 120 | files = [ 121 | {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, 122 | {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, 123 | ] 124 | 125 | [[package]] 126 | name = "charset-normalizer" 127 | version = "3.0.1" 128 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 129 | optional = false 130 | python-versions = "*" 131 | files = [ 132 | {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, 133 | {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, 134 | {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, 135 | {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, 136 | {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, 137 | {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, 138 | {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, 139 | {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, 140 | {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, 141 | {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, 142 | {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, 143 | {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, 144 | {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, 145 | {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, 146 | {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, 147 | {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, 148 | {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, 149 | {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, 150 | {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, 151 | {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, 152 | {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, 153 | {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, 154 | {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, 155 | {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, 156 | {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, 157 | {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, 158 | {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, 159 | {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, 160 | {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, 161 | {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, 162 | {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, 163 | {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, 164 | {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, 165 | {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, 166 | {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, 167 | {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, 168 | {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, 169 | {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, 170 | {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, 171 | {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, 172 | {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, 173 | {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, 174 | {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, 175 | {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, 176 | {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, 177 | {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, 178 | {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, 179 | {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, 180 | {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, 181 | {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, 182 | {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, 183 | {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, 184 | {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, 185 | {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, 186 | {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, 187 | {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, 188 | {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, 189 | {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, 190 | {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, 191 | {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, 192 | {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, 193 | {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, 194 | {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, 195 | {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, 196 | {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, 197 | {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, 198 | {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, 199 | {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, 200 | {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, 201 | {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, 202 | {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, 203 | {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, 204 | {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, 205 | {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, 206 | {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, 207 | {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, 208 | {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, 209 | {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, 210 | {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, 211 | {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, 212 | {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, 213 | {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, 214 | {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, 215 | {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, 216 | {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, 217 | {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, 218 | {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, 219 | {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, 220 | ] 221 | 222 | [[package]] 223 | name = "click" 224 | version = "8.1.3" 225 | description = "Composable command line interface toolkit" 226 | optional = false 227 | python-versions = ">=3.7" 228 | files = [ 229 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 230 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 231 | ] 232 | 233 | [package.dependencies] 234 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 235 | 236 | [[package]] 237 | name = "codecov" 238 | version = "2.1.13" 239 | description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" 240 | optional = false 241 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 242 | files = [ 243 | {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, 244 | {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, 245 | ] 246 | 247 | [package.dependencies] 248 | coverage = "*" 249 | requests = ">=2.7.9" 250 | 251 | [[package]] 252 | name = "colorama" 253 | version = "0.4.6" 254 | description = "Cross-platform colored terminal text." 255 | optional = false 256 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 257 | files = [ 258 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 259 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 260 | ] 261 | 262 | [[package]] 263 | name = "colorlog" 264 | version = "6.7.0" 265 | description = "Add colours to the output of Python's logging module." 266 | optional = false 267 | python-versions = ">=3.6" 268 | files = [ 269 | {file = "colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662"}, 270 | {file = "colorlog-6.7.0.tar.gz", hash = "sha256:bd94bd21c1e13fac7bd3153f4bc3a7dc0eb0974b8bc2fdf1a989e474f6e582e5"}, 271 | ] 272 | 273 | [package.dependencies] 274 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 275 | 276 | [package.extras] 277 | development = ["black", "flake8", "mypy", "pytest", "types-colorama"] 278 | 279 | [[package]] 280 | name = "coverage" 281 | version = "7.2.1" 282 | description = "Code coverage measurement for Python" 283 | optional = false 284 | python-versions = ">=3.7" 285 | files = [ 286 | {file = "coverage-7.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49567ec91fc5e0b15356da07a2feabb421d62f52a9fff4b1ec40e9e19772f5f8"}, 287 | {file = "coverage-7.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2ef6cae70168815ed91388948b5f4fcc69681480a0061114db737f957719f03"}, 288 | {file = "coverage-7.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3004765bca3acd9e015794e5c2f0c9a05587f5e698127ff95e9cfba0d3f29339"}, 289 | {file = "coverage-7.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cca7c0b7f5881dfe0291ef09ba7bb1582cb92ab0aeffd8afb00c700bf692415a"}, 290 | {file = "coverage-7.2.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2167d116309f564af56f9aa5e75ef710ef871c5f9b313a83050035097b56820"}, 291 | {file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cb5f152fb14857cbe7f3e8c9a5d98979c4c66319a33cad6e617f0067c9accdc4"}, 292 | {file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:87dc37f16fb5e3a28429e094145bf7c1753e32bb50f662722e378c5851f7fdc6"}, 293 | {file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e191a63a05851f8bce77bc875e75457f9b01d42843f8bd7feed2fc26bbe60833"}, 294 | {file = "coverage-7.2.1-cp310-cp310-win32.whl", hash = "sha256:e3ea04b23b114572b98a88c85379e9e9ae031272ba1fb9b532aa934c621626d4"}, 295 | {file = "coverage-7.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0cf557827be7eca1c38a2480484d706693e7bb1929e129785fe59ec155a59de6"}, 296 | {file = "coverage-7.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:570c21a29493b350f591a4b04c158ce1601e8d18bdcd21db136fbb135d75efa6"}, 297 | {file = "coverage-7.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e872b082b32065ac2834149dc0adc2a2e6d8203080501e1e3c3c77851b466f9"}, 298 | {file = "coverage-7.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fac6343bae03b176e9b58104a9810df3cdccd5cfed19f99adfa807ffbf43cf9b"}, 299 | {file = "coverage-7.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abacd0a738e71b20e224861bc87e819ef46fedba2fb01bc1af83dfd122e9c319"}, 300 | {file = "coverage-7.2.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9256d4c60c4bbfec92721b51579c50f9e5062c21c12bec56b55292464873508"}, 301 | {file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80559eaf6c15ce3da10edb7977a1548b393db36cbc6cf417633eca05d84dd1ed"}, 302 | {file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0bd7e628f6c3ec4e7d2d24ec0e50aae4e5ae95ea644e849d92ae4805650b4c4e"}, 303 | {file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09643fb0df8e29f7417adc3f40aaf379d071ee8f0350ab290517c7004f05360b"}, 304 | {file = "coverage-7.2.1-cp311-cp311-win32.whl", hash = "sha256:1b7fb13850ecb29b62a447ac3516c777b0e7a09ecb0f4bb6718a8654c87dfc80"}, 305 | {file = "coverage-7.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:617a94ada56bbfe547aa8d1b1a2b8299e2ec1ba14aac1d4b26a9f7d6158e1273"}, 306 | {file = "coverage-7.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8649371570551d2fd7dee22cfbf0b61f1747cdfb2b7587bb551e4beaaa44cb97"}, 307 | {file = "coverage-7.2.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d2b9b5e70a21474c105a133ba227c61bc95f2ac3b66861143ce39a5ea4b3f84"}, 308 | {file = "coverage-7.2.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae82c988954722fa07ec5045c57b6d55bc1a0890defb57cf4a712ced65b26ddd"}, 309 | {file = "coverage-7.2.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:861cc85dfbf55a7a768443d90a07e0ac5207704a9f97a8eb753292a7fcbdfcfc"}, 310 | {file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0339dc3237c0d31c3b574f19c57985fcbe494280153bbcad33f2cdf469f4ac3e"}, 311 | {file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:5928b85416a388dd557ddc006425b0c37e8468bd1c3dc118c1a3de42f59e2a54"}, 312 | {file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8d3843ca645f62c426c3d272902b9de90558e9886f15ddf5efe757b12dd376f5"}, 313 | {file = "coverage-7.2.1-cp37-cp37m-win32.whl", hash = "sha256:6a034480e9ebd4e83d1aa0453fd78986414b5d237aea89a8fdc35d330aa13bae"}, 314 | {file = "coverage-7.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6fce673f79a0e017a4dc35e18dc7bb90bf6d307c67a11ad5e61ca8d42b87cbff"}, 315 | {file = "coverage-7.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f099da6958ddfa2ed84bddea7515cb248583292e16bb9231d151cd528eab657"}, 316 | {file = "coverage-7.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:97a3189e019d27e914ecf5c5247ea9f13261d22c3bb0cfcfd2a9b179bb36f8b1"}, 317 | {file = "coverage-7.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a81dbcf6c6c877986083d00b834ac1e84b375220207a059ad45d12f6e518a4e3"}, 318 | {file = "coverage-7.2.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2c3dde4c0b9be4b02067185136b7ee4681978228ad5ec1278fa74f5ca3e99"}, 319 | {file = "coverage-7.2.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a209d512d157379cc9ab697cbdbb4cfd18daa3e7eebaa84c3d20b6af0037384"}, 320 | {file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f3d07edb912a978915576a776756069dede66d012baa503022d3a0adba1b6afa"}, 321 | {file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8dca3c1706670297851bca1acff9618455122246bdae623be31eca744ade05ec"}, 322 | {file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b1991a6d64231a3e5bbe3099fb0dd7c9aeaa4275ad0e0aeff4cb9ef885c62ba2"}, 323 | {file = "coverage-7.2.1-cp38-cp38-win32.whl", hash = "sha256:22c308bc508372576ffa3d2dbc4824bb70d28eeb4fcd79d4d1aed663a06630d0"}, 324 | {file = "coverage-7.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:b0c0d46de5dd97f6c2d1b560bf0fcf0215658097b604f1840365296302a9d1fb"}, 325 | {file = "coverage-7.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4dd34a935de268a133e4741827ae951283a28c0125ddcdbcbba41c4b98f2dfef"}, 326 | {file = "coverage-7.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0f8318ed0f3c376cfad8d3520f496946977abde080439d6689d7799791457454"}, 327 | {file = "coverage-7.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:834c2172edff5a08d78e2f53cf5e7164aacabeb66b369f76e7bb367ca4e2d993"}, 328 | {file = "coverage-7.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4d70c853f0546855f027890b77854508bdb4d6a81242a9d804482e667fff6e6"}, 329 | {file = "coverage-7.2.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6450da4c7afc4534305b2b7d8650131e130610cea448ff240b6ab73d7eab63"}, 330 | {file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:99f4dd81b2bb8fc67c3da68b1f5ee1650aca06faa585cbc6818dbf67893c6d58"}, 331 | {file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bdd3f2f285ddcf2e75174248b2406189261a79e7fedee2ceeadc76219b6faa0e"}, 332 | {file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f29351393eb05e6326f044a7b45ed8e38cb4dcc38570d12791f271399dc41431"}, 333 | {file = "coverage-7.2.1-cp39-cp39-win32.whl", hash = "sha256:e2b50ebc2b6121edf352336d503357321b9d8738bb7a72d06fc56153fd3f4cd8"}, 334 | {file = "coverage-7.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:bd5a12239c0006252244f94863f1c518ac256160cd316ea5c47fb1a11b25889a"}, 335 | {file = "coverage-7.2.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:436313d129db7cf5b4ac355dd2bd3f7c7e5294af077b090b85de75f8458b8616"}, 336 | {file = "coverage-7.2.1.tar.gz", hash = "sha256:c77f2a9093ccf329dd523a9b2b3c854c20d2a3d968b6def3b820272ca6732242"}, 337 | ] 338 | 339 | [package.dependencies] 340 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 341 | 342 | [package.extras] 343 | toml = ["tomli"] 344 | 345 | [[package]] 346 | name = "distlib" 347 | version = "0.3.6" 348 | description = "Distribution utilities" 349 | optional = false 350 | python-versions = "*" 351 | files = [ 352 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 353 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 354 | ] 355 | 356 | [[package]] 357 | name = "dparse" 358 | version = "0.6.2" 359 | description = "A parser for Python dependency files" 360 | optional = false 361 | python-versions = ">=3.5" 362 | files = [ 363 | {file = "dparse-0.6.2-py3-none-any.whl", hash = "sha256:8097076f1dd26c377f30d4745e6ec18fef42f3bf493933b842ac5bafad8c345f"}, 364 | {file = "dparse-0.6.2.tar.gz", hash = "sha256:d45255bda21f998bc7ddf2afd5e62505ba6134756ba2d42a84c56b0826614dfe"}, 365 | ] 366 | 367 | [package.dependencies] 368 | packaging = "*" 369 | toml = "*" 370 | 371 | [package.extras] 372 | conda = ["pyyaml"] 373 | pipenv = ["pipenv"] 374 | 375 | [[package]] 376 | name = "exceptiongroup" 377 | version = "1.1.0" 378 | description = "Backport of PEP 654 (exception groups)" 379 | optional = false 380 | python-versions = ">=3.7" 381 | files = [ 382 | {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, 383 | {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, 384 | ] 385 | 386 | [package.extras] 387 | test = ["pytest (>=6)"] 388 | 389 | [[package]] 390 | name = "feedparser" 391 | version = "6.0.10" 392 | description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" 393 | optional = false 394 | python-versions = ">=3.6" 395 | files = [ 396 | {file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"}, 397 | {file = "feedparser-6.0.10.tar.gz", hash = "sha256:27da485f4637ce7163cdeab13a80312b93b7d0c1b775bef4a47629a3110bca51"}, 398 | ] 399 | 400 | [package.dependencies] 401 | sgmllib3k = "*" 402 | 403 | [[package]] 404 | name = "filelock" 405 | version = "3.9.0" 406 | description = "A platform independent file lock." 407 | optional = false 408 | python-versions = ">=3.7" 409 | files = [ 410 | {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"}, 411 | {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"}, 412 | ] 413 | 414 | [package.extras] 415 | docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] 416 | testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] 417 | 418 | [[package]] 419 | name = "flake8" 420 | version = "6.0.0" 421 | description = "the modular source code checker: pep8 pyflakes and co" 422 | optional = false 423 | python-versions = ">=3.8.1" 424 | files = [ 425 | {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, 426 | {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, 427 | ] 428 | 429 | [package.dependencies] 430 | mccabe = ">=0.7.0,<0.8.0" 431 | pycodestyle = ">=2.10.0,<2.11.0" 432 | pyflakes = ">=3.0.0,<3.1.0" 433 | 434 | [[package]] 435 | name = "flake8-bandit" 436 | version = "4.1.1" 437 | description = "Automated security testing with bandit and flake8." 438 | optional = false 439 | python-versions = ">=3.6" 440 | files = [ 441 | {file = "flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d"}, 442 | {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"}, 443 | ] 444 | 445 | [package.dependencies] 446 | bandit = ">=1.7.3" 447 | flake8 = ">=5.0.0" 448 | 449 | [[package]] 450 | name = "flake8-blind-except" 451 | version = "0.2.1" 452 | description = "A flake8 extension that checks for blind except: statements" 453 | optional = false 454 | python-versions = "*" 455 | files = [ 456 | {file = "flake8-blind-except-0.2.1.tar.gz", hash = "sha256:f25a575a9dcb3eeb3c760bf9c22db60b8b5a23120224ed1faa9a43f75dd7dd16"}, 457 | ] 458 | 459 | [[package]] 460 | name = "flake8-bugbear" 461 | version = "23.1.20" 462 | description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." 463 | optional = false 464 | python-versions = ">=3.7" 465 | files = [ 466 | {file = "flake8-bugbear-23.1.20.tar.gz", hash = "sha256:55902ab5a48c5ea53d8689ecd146eda548e72f2724192b9c1d68f6d975d13c06"}, 467 | {file = "flake8_bugbear-23.1.20-py3-none-any.whl", hash = "sha256:04a115e5f9c8e87c38bdbbcdf9f58223ffe05469c07c9a7bd8633330bc4d078b"}, 468 | ] 469 | 470 | [package.dependencies] 471 | attrs = ">=19.2.0" 472 | flake8 = ">=3.0.0" 473 | 474 | [package.extras] 475 | dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] 476 | 477 | [[package]] 478 | name = "flake8-builtins" 479 | version = "2.1.0" 480 | description = "Check for python builtins being used as variables or parameters." 481 | optional = false 482 | python-versions = ">=3.7" 483 | files = [ 484 | {file = "flake8-builtins-2.1.0.tar.gz", hash = "sha256:12ff1ee96dd4e1f3141141ee6c45a5c7d3b3c440d0949e9b8d345c42b39c51d4"}, 485 | {file = "flake8_builtins-2.1.0-py3-none-any.whl", hash = "sha256:469e8f03d6d0edf4b1e62b6d5a97dce4598592c8a13ec8f0952e7a185eba50a1"}, 486 | ] 487 | 488 | [package.dependencies] 489 | flake8 = "*" 490 | 491 | [package.extras] 492 | test = ["pytest"] 493 | 494 | [[package]] 495 | name = "flake8-debugger" 496 | version = "4.1.2" 497 | description = "ipdb/pdb statement checker plugin for flake8" 498 | optional = false 499 | python-versions = ">=3.7" 500 | files = [ 501 | {file = "flake8-debugger-4.1.2.tar.gz", hash = "sha256:52b002560941e36d9bf806fca2523dc7fb8560a295d5f1a6e15ac2ded7a73840"}, 502 | {file = "flake8_debugger-4.1.2-py3-none-any.whl", hash = "sha256:0a5e55aeddcc81da631ad9c8c366e7318998f83ff00985a49e6b3ecf61e571bf"}, 503 | ] 504 | 505 | [package.dependencies] 506 | flake8 = ">=3.0" 507 | pycodestyle = "*" 508 | 509 | [[package]] 510 | name = "flake8-logging-format" 511 | version = "0.9.0" 512 | description = "" 513 | optional = false 514 | python-versions = "*" 515 | files = [ 516 | {file = "flake8-logging-format-0.9.0.tar.gz", hash = "sha256:e830cc49091e4b8ab9ea3da69a3da074bd631ce9a7db300e5c89fb48ba4a6986"}, 517 | ] 518 | 519 | [package.extras] 520 | lint = ["flake8"] 521 | test = ["PyHamcrest", "pytest", "pytest-cov"] 522 | 523 | [[package]] 524 | name = "flake8-use-fstring" 525 | version = "1.4" 526 | description = "Flake8 plugin for string formatting style." 527 | optional = false 528 | python-versions = ">=3.6" 529 | files = [ 530 | {file = "flake8-use-fstring-1.4.tar.gz", hash = "sha256:6550bf722585eb97dffa8343b0f1c372101f5c4ab5b07ebf0edd1c79880cdd39"}, 531 | ] 532 | 533 | [package.dependencies] 534 | flake8 = ">=3" 535 | 536 | [package.extras] 537 | ci = ["coverage (==4.*)", "coveralls", "flake8-builtins", "flake8-commas", "flake8-fixme", "flake8-print", "flake8-quotes", "flake8-todo", "pytest (>=4)", "pytest-cov (>=2)"] 538 | dev = ["coverage (==4.*)", "flake8-builtins", "flake8-commas", "flake8-fixme", "flake8-print", "flake8-quotes", "flake8-todo", "pytest (>=4)", "pytest-cov (>=2)"] 539 | test = ["coverage (==4.*)", "flake8-builtins", "flake8-commas", "flake8-fixme", "flake8-print", "flake8-quotes", "flake8-todo", "pytest (>=4)", "pytest-cov (>=2)"] 540 | 541 | [[package]] 542 | name = "freezegun" 543 | version = "1.2.2" 544 | description = "Let your Python tests travel through time" 545 | optional = false 546 | python-versions = ">=3.6" 547 | files = [ 548 | {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, 549 | {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, 550 | ] 551 | 552 | [package.dependencies] 553 | python-dateutil = ">=2.7" 554 | 555 | [[package]] 556 | name = "gitdb" 557 | version = "4.0.10" 558 | description = "Git Object Database" 559 | optional = false 560 | python-versions = ">=3.7" 561 | files = [ 562 | {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, 563 | {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, 564 | ] 565 | 566 | [package.dependencies] 567 | smmap = ">=3.0.1,<6" 568 | 569 | [[package]] 570 | name = "gitpython" 571 | version = "3.1.41" 572 | description = "GitPython is a Python library used to interact with Git repositories" 573 | optional = false 574 | python-versions = ">=3.7" 575 | files = [ 576 | {file = "GitPython-3.1.41-py3-none-any.whl", hash = "sha256:c36b6634d069b3f719610175020a9aed919421c87552185b085e04fbbdb10b7c"}, 577 | {file = "GitPython-3.1.41.tar.gz", hash = "sha256:ed66e624884f76df22c8e16066d567aaa5a37d5b5fa19db2c6df6f7156db9048"}, 578 | ] 579 | 580 | [package.dependencies] 581 | gitdb = ">=4.0.1,<5" 582 | 583 | [package.extras] 584 | test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "sumtypes"] 585 | 586 | [[package]] 587 | name = "greenlet" 588 | version = "2.0.2" 589 | description = "Lightweight in-process concurrent programming" 590 | optional = false 591 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" 592 | files = [ 593 | {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, 594 | {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, 595 | {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, 596 | {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, 597 | {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, 598 | {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, 599 | {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, 600 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, 601 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, 602 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, 603 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, 604 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, 605 | {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, 606 | {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, 607 | {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, 608 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, 609 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, 610 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, 611 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, 612 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, 613 | {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, 614 | {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, 615 | {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, 616 | {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, 617 | {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, 618 | {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, 619 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, 620 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, 621 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, 622 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, 623 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, 624 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, 625 | {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, 626 | {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, 627 | {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, 628 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, 629 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, 630 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, 631 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, 632 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, 633 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, 634 | {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, 635 | {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, 636 | {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, 637 | {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, 638 | {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, 639 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, 640 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, 641 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, 642 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, 643 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, 644 | {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, 645 | {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, 646 | {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, 647 | {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, 648 | {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, 649 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, 650 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, 651 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, 652 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, 653 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, 654 | {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, 655 | {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, 656 | {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, 657 | ] 658 | 659 | [package.extras] 660 | docs = ["Sphinx", "docutils (<0.18)"] 661 | test = ["objgraph", "psutil"] 662 | 663 | [[package]] 664 | name = "humanize" 665 | version = "4.5.0" 666 | description = "Python humanize utilities" 667 | optional = false 668 | python-versions = ">=3.7" 669 | files = [ 670 | {file = "humanize-4.5.0-py3-none-any.whl", hash = "sha256:127e333677183070b82e90e0faef9440f9a16dab92143e52f4523afb08ca9290"}, 671 | {file = "humanize-4.5.0.tar.gz", hash = "sha256:d6ed00ed4dc59a66df71012e3d69cf655d7d21b02112d435871998969e8aedc8"}, 672 | ] 673 | 674 | [package.extras] 675 | tests = ["freezegun", "pytest", "pytest-cov"] 676 | 677 | [[package]] 678 | name = "identify" 679 | version = "2.5.17" 680 | description = "File identification library for Python" 681 | optional = false 682 | python-versions = ">=3.7" 683 | files = [ 684 | {file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"}, 685 | {file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"}, 686 | ] 687 | 688 | [package.extras] 689 | license = ["ukkonen"] 690 | 691 | [[package]] 692 | name = "idna" 693 | version = "3.7" 694 | description = "Internationalized Domain Names in Applications (IDNA)" 695 | optional = false 696 | python-versions = ">=3.5" 697 | files = [ 698 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 699 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 700 | ] 701 | 702 | [[package]] 703 | name = "importlib-metadata" 704 | version = "3.10.1" 705 | description = "Read metadata from Python packages" 706 | optional = false 707 | python-versions = ">=3.6" 708 | files = [ 709 | {file = "importlib_metadata-3.10.1-py3-none-any.whl", hash = "sha256:2ec0faae539743ae6aaa84b49a169670a465f7f5d64e6add98388cc29fd1f2f6"}, 710 | {file = "importlib_metadata-3.10.1.tar.gz", hash = "sha256:c9356b657de65c53744046fa8f7358afe0714a1af7d570c00c3835c2d724a7c1"}, 711 | ] 712 | 713 | [package.dependencies] 714 | zipp = ">=0.5" 715 | 716 | [package.extras] 717 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 718 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] 719 | 720 | [[package]] 721 | name = "iniconfig" 722 | version = "2.0.0" 723 | description = "brain-dead simple config-ini parsing" 724 | optional = false 725 | python-versions = ">=3.7" 726 | files = [ 727 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 728 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 729 | ] 730 | 731 | [[package]] 732 | name = "isort" 733 | version = "5.12.0" 734 | description = "A Python utility / library to sort Python imports." 735 | optional = false 736 | python-versions = ">=3.8.0" 737 | files = [ 738 | {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, 739 | {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, 740 | ] 741 | 742 | [package.extras] 743 | colors = ["colorama (>=0.4.3)"] 744 | pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] 745 | plugins = ["setuptools"] 746 | requirements-deprecated-finder = ["pip-api", "pipreqs"] 747 | 748 | [[package]] 749 | name = "jinja2" 750 | version = "3.1.4" 751 | description = "A very fast and expressive template engine." 752 | optional = false 753 | python-versions = ">=3.7" 754 | files = [ 755 | {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, 756 | {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, 757 | ] 758 | 759 | [package.dependencies] 760 | MarkupSafe = ">=2.0" 761 | 762 | [package.extras] 763 | i18n = ["Babel (>=2.7)"] 764 | 765 | [[package]] 766 | name = "markupsafe" 767 | version = "2.1.5" 768 | description = "Safely add untrusted strings to HTML/XML markup." 769 | optional = false 770 | python-versions = ">=3.7" 771 | files = [ 772 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, 773 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, 774 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, 775 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, 776 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, 777 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, 778 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, 779 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, 780 | {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, 781 | {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, 782 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, 783 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, 784 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, 785 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, 786 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, 787 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, 788 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, 789 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, 790 | {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, 791 | {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, 792 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, 793 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, 794 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, 795 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, 796 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, 797 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, 798 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, 799 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, 800 | {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, 801 | {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, 802 | {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, 803 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, 804 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, 805 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, 806 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, 807 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, 808 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, 809 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, 810 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, 811 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, 812 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, 813 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, 814 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, 815 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, 816 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, 817 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, 818 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, 819 | {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, 820 | {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, 821 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, 822 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, 823 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, 824 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, 825 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, 826 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, 827 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, 828 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, 829 | {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, 830 | {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, 831 | {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, 832 | ] 833 | 834 | [[package]] 835 | name = "marshmallow" 836 | version = "3.21.1" 837 | description = "A lightweight library for converting complex datatypes to and from native Python datatypes." 838 | optional = false 839 | python-versions = ">=3.8" 840 | files = [ 841 | {file = "marshmallow-3.21.1-py3-none-any.whl", hash = "sha256:f085493f79efb0644f270a9bf2892843142d80d7174bbbd2f3713f2a589dc633"}, 842 | {file = "marshmallow-3.21.1.tar.gz", hash = "sha256:4e65e9e0d80fc9e609574b9983cf32579f305c718afb30d7233ab818571768c3"}, 843 | ] 844 | 845 | [package.dependencies] 846 | packaging = ">=17.0" 847 | 848 | [package.extras] 849 | dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] 850 | docs = ["alabaster (==0.7.16)", "autodocsumm (==0.2.12)", "sphinx (==7.2.6)", "sphinx-issues (==4.0.0)", "sphinx-version-warning (==1.1.2)"] 851 | tests = ["pytest", "pytz", "simplejson"] 852 | 853 | [[package]] 854 | name = "mccabe" 855 | version = "0.7.0" 856 | description = "McCabe checker, plugin for flake8" 857 | optional = false 858 | python-versions = ">=3.6" 859 | files = [ 860 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 861 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 862 | ] 863 | 864 | [[package]] 865 | name = "mypy" 866 | version = "0.991" 867 | description = "Optional static typing for Python" 868 | optional = false 869 | python-versions = ">=3.7" 870 | files = [ 871 | {file = "mypy-0.991-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab"}, 872 | {file = "mypy-0.991-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d"}, 873 | {file = "mypy-0.991-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6"}, 874 | {file = "mypy-0.991-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb"}, 875 | {file = "mypy-0.991-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305"}, 876 | {file = "mypy-0.991-cp310-cp310-win_amd64.whl", hash = "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c"}, 877 | {file = "mypy-0.991-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372"}, 878 | {file = "mypy-0.991-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f"}, 879 | {file = "mypy-0.991-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33"}, 880 | {file = "mypy-0.991-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05"}, 881 | {file = "mypy-0.991-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad"}, 882 | {file = "mypy-0.991-cp311-cp311-win_amd64.whl", hash = "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297"}, 883 | {file = "mypy-0.991-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813"}, 884 | {file = "mypy-0.991-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711"}, 885 | {file = "mypy-0.991-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd"}, 886 | {file = "mypy-0.991-cp37-cp37m-win_amd64.whl", hash = "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef"}, 887 | {file = "mypy-0.991-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a"}, 888 | {file = "mypy-0.991-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93"}, 889 | {file = "mypy-0.991-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf"}, 890 | {file = "mypy-0.991-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135"}, 891 | {file = "mypy-0.991-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70"}, 892 | {file = "mypy-0.991-cp38-cp38-win_amd64.whl", hash = "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243"}, 893 | {file = "mypy-0.991-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d"}, 894 | {file = "mypy-0.991-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5"}, 895 | {file = "mypy-0.991-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3"}, 896 | {file = "mypy-0.991-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648"}, 897 | {file = "mypy-0.991-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476"}, 898 | {file = "mypy-0.991-cp39-cp39-win_amd64.whl", hash = "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461"}, 899 | {file = "mypy-0.991-py3-none-any.whl", hash = "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb"}, 900 | {file = "mypy-0.991.tar.gz", hash = "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06"}, 901 | ] 902 | 903 | [package.dependencies] 904 | mypy-extensions = ">=0.4.3" 905 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 906 | typing-extensions = ">=3.10" 907 | 908 | [package.extras] 909 | dmypy = ["psutil (>=4.0)"] 910 | install-types = ["pip"] 911 | python2 = ["typed-ast (>=1.4.0,<2)"] 912 | reports = ["lxml"] 913 | 914 | [[package]] 915 | name = "mypy-extensions" 916 | version = "0.4.3" 917 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 918 | optional = false 919 | python-versions = "*" 920 | files = [ 921 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 922 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 923 | ] 924 | 925 | [[package]] 926 | name = "nodeenv" 927 | version = "1.7.0" 928 | description = "Node.js virtual environment builder" 929 | optional = false 930 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 931 | files = [ 932 | {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, 933 | {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, 934 | ] 935 | 936 | [package.dependencies] 937 | setuptools = "*" 938 | 939 | [[package]] 940 | name = "nox" 941 | version = "2022.11.21" 942 | description = "Flexible test automation." 943 | optional = false 944 | python-versions = ">=3.7" 945 | files = [ 946 | {file = "nox-2022.11.21-py3-none-any.whl", hash = "sha256:0e41a990e290e274cb205a976c4c97ee3c5234441a8132c8c3fd9ea3c22149eb"}, 947 | {file = "nox-2022.11.21.tar.gz", hash = "sha256:e21c31de0711d1274ca585a2c5fde36b1aa962005ba8e9322bf5eeed16dcd684"}, 948 | ] 949 | 950 | [package.dependencies] 951 | argcomplete = ">=1.9.4,<3.0" 952 | colorlog = ">=2.6.1,<7.0.0" 953 | packaging = ">=20.9" 954 | virtualenv = ">=14" 955 | 956 | [package.extras] 957 | tox-to-nox = ["jinja2", "tox"] 958 | 959 | [[package]] 960 | name = "packaging" 961 | version = "24.0" 962 | description = "Core utilities for Python packages" 963 | optional = false 964 | python-versions = ">=3.7" 965 | files = [ 966 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 967 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 968 | ] 969 | 970 | [[package]] 971 | name = "pathspec" 972 | version = "0.11.0" 973 | description = "Utility library for gitignore style pattern matching of file paths." 974 | optional = false 975 | python-versions = ">=3.7" 976 | files = [ 977 | {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, 978 | {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, 979 | ] 980 | 981 | [[package]] 982 | name = "pbr" 983 | version = "5.11.1" 984 | description = "Python Build Reasonableness" 985 | optional = false 986 | python-versions = ">=2.6" 987 | files = [ 988 | {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, 989 | {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, 990 | ] 991 | 992 | [[package]] 993 | name = "platformdirs" 994 | version = "2.6.2" 995 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 996 | optional = false 997 | python-versions = ">=3.7" 998 | files = [ 999 | {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, 1000 | {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, 1001 | ] 1002 | 1003 | [package.extras] 1004 | docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] 1005 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 1006 | 1007 | [[package]] 1008 | name = "pluggy" 1009 | version = "1.0.0" 1010 | description = "plugin and hook calling mechanisms for python" 1011 | optional = false 1012 | python-versions = ">=3.6" 1013 | files = [ 1014 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 1015 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 1016 | ] 1017 | 1018 | [package.extras] 1019 | dev = ["pre-commit", "tox"] 1020 | testing = ["pytest", "pytest-benchmark"] 1021 | 1022 | [[package]] 1023 | name = "pre-commit" 1024 | version = "3.0.3" 1025 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 1026 | optional = false 1027 | python-versions = ">=3.8" 1028 | files = [ 1029 | {file = "pre_commit-3.0.3-py2.py3-none-any.whl", hash = "sha256:83e2e8cc5cbb3691cff9474494816918d865120768aa36c9eda6185126667d21"}, 1030 | {file = "pre_commit-3.0.3.tar.gz", hash = "sha256:4187e74fda38f0f700256fb2f757774385503b04292047d0899fc913207f314b"}, 1031 | ] 1032 | 1033 | [package.dependencies] 1034 | cfgv = ">=2.0.0" 1035 | identify = ">=1.0.0" 1036 | nodeenv = ">=0.11.1" 1037 | pyyaml = ">=5.1" 1038 | virtualenv = ">=20.10.0" 1039 | 1040 | [[package]] 1041 | name = "psycopg2-binary" 1042 | version = "2.9.5" 1043 | description = "psycopg2 - Python-PostgreSQL Database Adapter" 1044 | optional = false 1045 | python-versions = ">=3.6" 1046 | files = [ 1047 | {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, 1048 | {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:0775d6252ccb22b15da3b5d7adbbf8cfe284916b14b6dc0ff503a23edb01ee85"}, 1049 | {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632"}, 1050 | {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3520d7af1ebc838cc6084a3281145d5cd5bdd43fdef139e6db5af01b92596cb7"}, 1051 | {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cbc554ba47ecca8cd3396ddaca85e1ecfe3e48dd57dc5e415e59551affe568e"}, 1052 | {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:5d28ecdf191db558d0c07d0f16524ee9d67896edf2b7990eea800abeb23ebd61"}, 1053 | {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64"}, 1054 | {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05b3d479425e047c848b9782cd7aac9c6727ce23181eb9647baf64ffdfc3da41"}, 1055 | {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e491e6489a6cb1d079df8eaa15957c277fdedb102b6a68cfbf40c4994412fd0"}, 1056 | {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:9e32cedc389bcb76d9f24ea8a012b3cb8385ee362ea437e1d012ffaed106c17d"}, 1057 | {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"}, 1058 | {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"}, 1059 | {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"}, 1060 | {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_10_9_universal2.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:426c2ae999135d64e6a18849a7d1ad0e1bd007277e4a8f4752eaa40a96b550ff"}, 1061 | {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"}, 1062 | {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"}, 1063 | {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"}, 1064 | {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577"}, 1065 | {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:af0516e1711995cb08dc19bbd05bec7dbdebf4185f68870595156718d237df3e"}, 1066 | {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e72c91bda9880f097c8aa3601a2c0de6c708763ba8128006151f496ca9065935"}, 1067 | {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"}, 1068 | {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"}, 1069 | {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, 1070 | {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"}, 1071 | {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"}, 1072 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"}, 1073 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"}, 1074 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, 1075 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:56b2957a145f816726b109ee3d4e6822c23f919a7d91af5a94593723ed667835"}, 1076 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69"}, 1077 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:70831e03bd53702c941da1a1ad36c17d825a24fbb26857b40913d58df82ec18b"}, 1078 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dbc332beaf8492b5731229a881807cd7b91b50dbbbaf7fe2faf46942eda64a24"}, 1079 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba"}, 1080 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:95076399ec3b27a8f7fa1cc9a83417b1c920d55cf7a97f718a94efbb96c7f503"}, 1081 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-win32.whl", hash = "sha256:3fc33295cfccad697a97a76dec3f1e94ad848b7b163c3228c1636977966b51e2"}, 1082 | {file = "psycopg2_binary-2.9.5-cp36-cp36m-win_amd64.whl", hash = "sha256:02551647542f2bf89073d129c73c05a25c372fc0a49aa50e0de65c3c143d8bd0"}, 1083 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:63e318dbe52709ed10d516a356f22a635e07a2e34c68145484ed96a19b0c4c68"}, 1084 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e518a0911c50f60313cb9e74a169a65b5d293770db4770ebf004245f24b5c5"}, 1085 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb"}, 1086 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:68d81a2fe184030aa0c5c11e518292e15d342a667184d91e30644c9d533e53e1"}, 1087 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:7ee3095d02d6f38bd7d9a5358fcc9ea78fcdb7176921528dd709cc63f40184f5"}, 1088 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:46512486be6fbceef51d7660dec017394ba3e170299d1dc30928cbedebbf103a"}, 1089 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b911dfb727e247340d36ae20c4b9259e4a64013ab9888ccb3cbba69b77fd9636"}, 1090 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:422e3d43b47ac20141bc84b3d342eead8d8099a62881a501e97d15f6addabfe9"}, 1091 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7"}, 1092 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-win32.whl", hash = "sha256:b8104f709590fff72af801e916817560dbe1698028cd0afe5a52d75ceb1fce5f"}, 1093 | {file = "psycopg2_binary-2.9.5-cp37-cp37m-win_amd64.whl", hash = "sha256:7b3751857da3e224f5629400736a7b11e940b5da5f95fa631d86219a1beaafec"}, 1094 | {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:043a9fd45a03858ff72364b4b75090679bd875ee44df9c0613dc862ca6b98460"}, 1095 | {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9ffdc51001136b699f9563b1c74cc1f8c07f66ef7219beb6417a4c8aaa896c28"}, 1096 | {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896"}, 1097 | {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc85b3777068ed30aff8242be2813038a929f2084f69e43ef869daddae50f6ee"}, 1098 | {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147"}, 1099 | {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:7d07f552d1e412f4b4e64ce386d4c777a41da3b33f7098b6219012ba534fb2c2"}, 1100 | {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a0adef094c49f242122bb145c3c8af442070dc0e4312db17e49058c1702606d4"}, 1101 | {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00475004e5ed3e3bf5e056d66e5dcdf41a0dc62efcd57997acd9135c40a08a50"}, 1102 | {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7d88db096fa19d94f433420eaaf9f3c45382da2dd014b93e4bf3215639047c16"}, 1103 | {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:902844f9c4fb19b17dfa84d9e2ca053d4a4ba265723d62ea5c9c26b38e0aa1e6"}, 1104 | {file = "psycopg2_binary-2.9.5-cp38-cp38-win32.whl", hash = "sha256:4e7904d1920c0c89105c0517dc7e3f5c20fb4e56ba9cdef13048db76947f1d79"}, 1105 | {file = "psycopg2_binary-2.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:a36a0e791805aa136e9cbd0ffa040d09adec8610453ee8a753f23481a0057af5"}, 1106 | {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c"}, 1107 | {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9c38d3869238e9d3409239bc05bc27d6b7c99c2a460ea337d2814b35fb4fea1b"}, 1108 | {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6527c8efa5226a9e787507652dd5ba97b62d29b53c371a85cd13f957fe4d42"}, 1109 | {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59137cdb970249ae60be2a49774c6dfb015bd0403f05af1fe61862e9626642d"}, 1110 | {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:d4c7b3a31502184e856df1f7bbb2c3735a05a8ce0ade34c5277e1577738a5c91"}, 1111 | {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b9a794cef1d9c1772b94a72eec6da144c18e18041d294a9ab47669bc77a80c1d"}, 1112 | {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267"}, 1113 | {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5e65c6ac0ae4bf5bef1667029f81010b6017795dcb817ba5c7b8a8d61fab76f"}, 1114 | {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:74eddec4537ab1f701a1647214734bc52cee2794df748f6ae5908e00771f180a"}, 1115 | {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:01ad49d68dd8c5362e4bfb4158f2896dc6e0c02e87b8a3770fc003459f1a4425"}, 1116 | {file = "psycopg2_binary-2.9.5-cp39-cp39-win32.whl", hash = "sha256:937880290775033a743f4836aa253087b85e62784b63fd099ee725d567a48aa1"}, 1117 | {file = "psycopg2_binary-2.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:484405b883630f3e74ed32041a87456c5e0e63a8e3429aa93e8714c366d62bd1"}, 1118 | ] 1119 | 1120 | [[package]] 1121 | name = "pycodestyle" 1122 | version = "2.10.0" 1123 | description = "Python style guide checker" 1124 | optional = false 1125 | python-versions = ">=3.6" 1126 | files = [ 1127 | {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, 1128 | {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, 1129 | ] 1130 | 1131 | [[package]] 1132 | name = "pyflakes" 1133 | version = "3.0.1" 1134 | description = "passive checker of Python programs" 1135 | optional = false 1136 | python-versions = ">=3.6" 1137 | files = [ 1138 | {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, 1139 | {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, 1140 | ] 1141 | 1142 | [[package]] 1143 | name = "pyjokes" 1144 | version = "0.6.0" 1145 | description = "One line jokes for programmers (jokes as a service)" 1146 | optional = false 1147 | python-versions = "*" 1148 | files = [ 1149 | {file = "pyjokes-0.6.0-py2.py3-none-any.whl", hash = "sha256:70b6125186dee5845038505cd16b5e09250da46c730e36b44ffd870e3c81aaaa"}, 1150 | {file = "pyjokes-0.6.0.tar.gz", hash = "sha256:08860eedb78cbfa4618243c8db088f21c39823ece1fdaf0133e52d9c56e981a5"}, 1151 | ] 1152 | 1153 | [package.extras] 1154 | doc = ["mkdocs"] 1155 | test = ["coverage", "pytest", "tox"] 1156 | 1157 | [[package]] 1158 | name = "pytest" 1159 | version = "7.2.1" 1160 | description = "pytest: simple powerful testing with Python" 1161 | optional = false 1162 | python-versions = ">=3.7" 1163 | files = [ 1164 | {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, 1165 | {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, 1166 | ] 1167 | 1168 | [package.dependencies] 1169 | attrs = ">=19.2.0" 1170 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 1171 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 1172 | iniconfig = "*" 1173 | packaging = "*" 1174 | pluggy = ">=0.12,<2.0" 1175 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 1176 | 1177 | [package.extras] 1178 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 1179 | 1180 | [[package]] 1181 | name = "pytest-cov" 1182 | version = "4.0.0" 1183 | description = "Pytest plugin for measuring coverage." 1184 | optional = false 1185 | python-versions = ">=3.6" 1186 | files = [ 1187 | {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, 1188 | {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, 1189 | ] 1190 | 1191 | [package.dependencies] 1192 | coverage = {version = ">=5.2.1", extras = ["toml"]} 1193 | pytest = ">=4.6" 1194 | 1195 | [package.extras] 1196 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 1197 | 1198 | [[package]] 1199 | name = "pytest-mock" 1200 | version = "3.10.0" 1201 | description = "Thin-wrapper around the mock package for easier use with pytest" 1202 | optional = false 1203 | python-versions = ">=3.7" 1204 | files = [ 1205 | {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"}, 1206 | {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, 1207 | ] 1208 | 1209 | [package.dependencies] 1210 | pytest = ">=5.0" 1211 | 1212 | [package.extras] 1213 | dev = ["pre-commit", "pytest-asyncio", "tox"] 1214 | 1215 | [[package]] 1216 | name = "python-dateutil" 1217 | version = "2.8.2" 1218 | description = "Extensions to the standard Python datetime module" 1219 | optional = false 1220 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 1221 | files = [ 1222 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 1223 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 1224 | ] 1225 | 1226 | [package.dependencies] 1227 | six = ">=1.5" 1228 | 1229 | [[package]] 1230 | name = "python-dotenv" 1231 | version = "0.21.1" 1232 | description = "Read key-value pairs from a .env file and set them as environment variables" 1233 | optional = false 1234 | python-versions = ">=3.7" 1235 | files = [ 1236 | {file = "python-dotenv-0.21.1.tar.gz", hash = "sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49"}, 1237 | {file = "python_dotenv-0.21.1-py3-none-any.whl", hash = "sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a"}, 1238 | ] 1239 | 1240 | [package.extras] 1241 | cli = ["click (>=5.0)"] 1242 | 1243 | [[package]] 1244 | name = "pyyaml" 1245 | version = "6.0" 1246 | description = "YAML parser and emitter for Python" 1247 | optional = false 1248 | python-versions = ">=3.6" 1249 | files = [ 1250 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 1251 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 1252 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 1253 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 1254 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 1255 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 1256 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 1257 | {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, 1258 | {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, 1259 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, 1260 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, 1261 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, 1262 | {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, 1263 | {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, 1264 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 1265 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 1266 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 1267 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 1268 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 1269 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 1270 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 1271 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 1272 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 1273 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 1274 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 1275 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 1276 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 1277 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 1278 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 1279 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 1280 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 1281 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 1282 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 1283 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 1284 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 1285 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 1286 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 1287 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 1288 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 1289 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 1290 | ] 1291 | 1292 | [[package]] 1293 | name = "requests" 1294 | version = "2.32.0" 1295 | description = "Python HTTP for Humans." 1296 | optional = false 1297 | python-versions = ">=3.8" 1298 | files = [ 1299 | {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, 1300 | {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, 1301 | ] 1302 | 1303 | [package.dependencies] 1304 | certifi = ">=2017.4.17" 1305 | charset-normalizer = ">=2,<4" 1306 | idna = ">=2.5,<4" 1307 | urllib3 = ">=1.21.1,<3" 1308 | 1309 | [package.extras] 1310 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 1311 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 1312 | 1313 | [[package]] 1314 | name = "ruamel-yaml" 1315 | version = "0.17.21" 1316 | description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" 1317 | optional = false 1318 | python-versions = ">=3" 1319 | files = [ 1320 | {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, 1321 | {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, 1322 | ] 1323 | 1324 | [package.dependencies] 1325 | "ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} 1326 | 1327 | [package.extras] 1328 | docs = ["ryd"] 1329 | jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] 1330 | 1331 | [[package]] 1332 | name = "ruamel-yaml-clib" 1333 | version = "0.2.7" 1334 | description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" 1335 | optional = false 1336 | python-versions = ">=3.5" 1337 | files = [ 1338 | {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71"}, 1339 | {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7"}, 1340 | {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80"}, 1341 | {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab"}, 1342 | {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win32.whl", hash = "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231"}, 1343 | {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a"}, 1344 | {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e"}, 1345 | {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:1a6391a7cabb7641c32517539ca42cf84b87b667bad38b78d4d42dd23e957c81"}, 1346 | {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:9c7617df90c1365638916b98cdd9be833d31d337dbcd722485597b43c4a215bf"}, 1347 | {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94"}, 1348 | {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win32.whl", hash = "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38"}, 1349 | {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122"}, 1350 | {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072"}, 1351 | {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_12_0_arm64.whl", hash = "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8"}, 1352 | {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3"}, 1353 | {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763"}, 1354 | {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win32.whl", hash = "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e"}, 1355 | {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win_amd64.whl", hash = "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646"}, 1356 | {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f"}, 1357 | {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0"}, 1358 | {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282"}, 1359 | {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7"}, 1360 | {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win32.whl", hash = "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93"}, 1361 | {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b"}, 1362 | {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb"}, 1363 | {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307"}, 1364 | {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697"}, 1365 | {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b"}, 1366 | {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win32.whl", hash = "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac"}, 1367 | {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f"}, 1368 | {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9"}, 1369 | {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1"}, 1370 | {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640"}, 1371 | {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b"}, 1372 | {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win32.whl", hash = "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8"}, 1373 | {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5"}, 1374 | {file = "ruamel.yaml.clib-0.2.7.tar.gz", hash = "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497"}, 1375 | ] 1376 | 1377 | [[package]] 1378 | name = "safety" 1379 | version = "2.4.0b2" 1380 | description = "Checks installed dependencies for known vulnerabilities and licenses." 1381 | optional = false 1382 | python-versions = "*" 1383 | files = [ 1384 | {file = "safety-2.4.0b2-py3-none-any.whl", hash = "sha256:63773ce92e17f5f80e7dff4c8a25d8abb7d62d375897b5f3bb4afe9313b100ff"}, 1385 | {file = "safety-2.4.0b2.tar.gz", hash = "sha256:9907010c6ca7720861ca7fa1496bdb80449b0619ca136eb7ac7e02bd3516cd4f"}, 1386 | ] 1387 | 1388 | [package.dependencies] 1389 | Click = ">=8.0.2" 1390 | dparse = ">=0.6.2" 1391 | jinja2 = {version = ">=3.1.0", markers = "python_version >= \"3.7\""} 1392 | marshmallow = {version = ">=3.15.0", markers = "python_version >= \"3.7\""} 1393 | packaging = ">=21.0" 1394 | requests = "*" 1395 | "ruamel.yaml" = ">=0.17.21" 1396 | setuptools = {version = ">=65.5.1", markers = "python_version >= \"3.7\""} 1397 | urllib3 = ">=1.26.5" 1398 | 1399 | [package.extras] 1400 | github = ["pygithub (>=1.43.3)"] 1401 | gitlab = ["python-gitlab (>=1.3.0)"] 1402 | 1403 | [[package]] 1404 | name = "setuptools" 1405 | version = "70.0.0" 1406 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 1407 | optional = false 1408 | python-versions = ">=3.8" 1409 | files = [ 1410 | {file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"}, 1411 | {file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"}, 1412 | ] 1413 | 1414 | [package.extras] 1415 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 1416 | testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 1417 | 1418 | [[package]] 1419 | name = "sgmllib3k" 1420 | version = "1.0.0" 1421 | description = "Py3k port of sgmllib." 1422 | optional = false 1423 | python-versions = "*" 1424 | files = [ 1425 | {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, 1426 | ] 1427 | 1428 | [[package]] 1429 | name = "six" 1430 | version = "1.16.0" 1431 | description = "Python 2 and 3 compatibility utilities" 1432 | optional = false 1433 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 1434 | files = [ 1435 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 1436 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 1437 | ] 1438 | 1439 | [[package]] 1440 | name = "slack-bolt" 1441 | version = "1.16.1" 1442 | description = "The Bolt Framework for Python" 1443 | optional = false 1444 | python-versions = ">=3.6" 1445 | files = [ 1446 | {file = "slack_bolt-1.16.1-py2.py3-none-any.whl", hash = "sha256:96b584c0f568fa0be5c8177cc5628a7772ff28067fc5a6bfc174d170048a36e5"}, 1447 | {file = "slack_bolt-1.16.1.tar.gz", hash = "sha256:ecef3da9b7c98b0d4333141c461caa405fb62f4b79a7bead6a35445164473cf7"}, 1448 | ] 1449 | 1450 | [package.dependencies] 1451 | slack-sdk = ">=3.18.5,<4" 1452 | 1453 | [package.extras] 1454 | adapter = ["CherryPy (>=18,<19)", "Django (>=3,<5)", "Flask (>=1,<3)", "Werkzeug (>=2,<3)", "boto3 (<=2)", "bottle (>=0.12,<1)", "chalice (>=1.27.3,<2)", "falcon (>=2,<4)", "fastapi (>=0.70.0,<1)", "gunicorn (>=20,<21)", "pyramid (>=1,<3)", "sanic (>=22,<23)", "starlette (>=0.14,<1)", "tornado (>=6,<7)", "uvicorn (<1)", "websocket-client (>=1.2.3,<2)"] 1455 | adapter-testing = ["Flask (>=1,<2)", "Werkzeug (>=1,<2)", "boddle (>=0.2,<0.3)", "docker (>=5,<6)", "moto (>=3,<4)", "requests (>=2,<3)", "sanic-testing (>=0.7)"] 1456 | async = ["aiohttp (>=3,<4)", "websockets (>=10,<11)"] 1457 | testing = ["Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "Werkzeug (>=1,<2)", "aiohttp (>=3,<4)", "black (==22.8.0)", "click (<=8.0.4)", "itsdangerous (==2.0.1)", "pytest (>=6.2.5,<7)", "pytest-asyncio (>=0.18.2,<1)", "pytest-cov (>=3,<4)"] 1458 | testing-without-asyncio = ["Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "Werkzeug (>=1,<2)", "black (==22.8.0)", "click (<=8.0.4)", "itsdangerous (==2.0.1)", "pytest (>=6.2.5,<7)", "pytest-cov (>=3,<4)"] 1459 | 1460 | [[package]] 1461 | name = "slack-sdk" 1462 | version = "3.19.5" 1463 | description = "The Slack API Platform SDK for Python" 1464 | optional = false 1465 | python-versions = ">=3.6.0" 1466 | files = [ 1467 | {file = "slack_sdk-3.19.5-py2.py3-none-any.whl", hash = "sha256:0b52bb32a87c71f638b9eb47e228dffeebf89de5e762684ef848276f9f186c84"}, 1468 | {file = "slack_sdk-3.19.5.tar.gz", hash = "sha256:47fb4af596243fe6585a92f3034de21eb2104a55cc9fd59a92ef3af17cf9ddd8"}, 1469 | ] 1470 | 1471 | [package.extras] 1472 | optional = ["SQLAlchemy (>=1,<2)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=10,<11)"] 1473 | testing = ["Flask (>=1,<2)", "Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "Werkzeug (<2)", "black (==22.8.0)", "boto3 (<=2)", "click (==8.0.4)", "codecov (>=2,<3)", "databases (>=0.5)", "flake8 (>=5,<6)", "itsdangerous (==1.1.0)", "moto (>=3,<4)", "psutil (>=5,<6)", "pytest (>=6.2.5,<7)", "pytest-asyncio (<1)", "pytest-cov (>=2,<3)"] 1474 | 1475 | [[package]] 1476 | name = "smmap" 1477 | version = "5.0.0" 1478 | description = "A pure Python implementation of a sliding window memory map manager" 1479 | optional = false 1480 | python-versions = ">=3.6" 1481 | files = [ 1482 | {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, 1483 | {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, 1484 | ] 1485 | 1486 | [[package]] 1487 | name = "sqlalchemy" 1488 | version = "2.0.1" 1489 | description = "Database Abstraction Library" 1490 | optional = false 1491 | python-versions = ">=3.7" 1492 | files = [ 1493 | {file = "SQLAlchemy-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3997238968fa495fac4b17fa18b36616c41a6e6759f323dfb3f83cbcf1d3b1bb"}, 1494 | {file = "SQLAlchemy-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d1588f6ba25dbb2d6eb1531e56f419e02cdc9ec06d9f082195877c5148f6f6ab"}, 1495 | {file = "SQLAlchemy-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01704ec4a6877b74608264992a87979a27a8927cefd14ccdc0d478acacc1ed85"}, 1496 | {file = "SQLAlchemy-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e60bec8fdd753212aa8cec012bbb3060e9c2227496fa935ca8918744a34c864d"}, 1497 | {file = "SQLAlchemy-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:eff376cc201363634b5b60a828b3998b088a71e16f7a43da26fc0e2201e25a05"}, 1498 | {file = "SQLAlchemy-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:20b9e36f0219285c580dc5e98cadb59b751e259f3829460bc58d45e7a770dd36"}, 1499 | {file = "SQLAlchemy-2.0.1-cp310-cp310-win32.whl", hash = "sha256:0186b970fd4561def531b582a86819d8f8af65c8b1a78cf015ee47e526f4cfb6"}, 1500 | {file = "SQLAlchemy-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:664a21613d7eff895de9ef731632575cfca773ddbac9b7f7adad288ab971bcbd"}, 1501 | {file = "SQLAlchemy-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1f504779e6e68d0cb7043825958125abd7742c7c73ce9c6b652d20c6b5f17022"}, 1502 | {file = "SQLAlchemy-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f9085bedb9e2f2bf714cfd86be6deaa7050f998843a3a0e595ec3eb0d25c743"}, 1503 | {file = "SQLAlchemy-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fcc9b2f5b334fdaf278459dfc0fb86d3a0317ae8ce813a7a3ef8639b44b6e4a"}, 1504 | {file = "SQLAlchemy-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97c4b5527ea563867ccbee031af93932d9699c6c73f1ea70adcbc935c80379e"}, 1505 | {file = "SQLAlchemy-2.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f707729cc35dbd1d672b11037f5464b8a42c1e89772d7fc60648da215fa72fc6"}, 1506 | {file = "SQLAlchemy-2.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3846d36c1ca113a7fa078abb5e69a8c3d1c7642baf12267dcd9a0d660cf1bdeb"}, 1507 | {file = "SQLAlchemy-2.0.1-cp311-cp311-win32.whl", hash = "sha256:aeb49e1436d6558d31c006b385a5071e802be6db257ce36940e66cefce92aa72"}, 1508 | {file = "SQLAlchemy-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9efb27e899cf7d43cf42c0852ef772a8b568c39dc7b55768a5a80c67bb64dfc2"}, 1509 | {file = "SQLAlchemy-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7e23205506a437476dce8193357ce47254cce7c94018b1b4856476ad2e74f1ae"}, 1510 | {file = "SQLAlchemy-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c35b33a0828b4f5ac6e76a1b6a54b27d693599c93ea7a4c8e53ff52796378f"}, 1511 | {file = "SQLAlchemy-2.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca2ce5f3125cb6e043c90dd901446b74878f35eb6660e0e58d7ef02832f7d332"}, 1512 | {file = "SQLAlchemy-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:eeec87ebe90018bc871b84b03e4bff5dbdc722e28b8f5a6e9a94486eb0cb4902"}, 1513 | {file = "SQLAlchemy-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:476bd377f430b1871f058332696ef61c42dfa8ad242ebb8bcf212c3d4127ea8a"}, 1514 | {file = "SQLAlchemy-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:101df3fa8f207ade1124d7729f6c9eab28a2560baa31b3e131e76a599d884b33"}, 1515 | {file = "SQLAlchemy-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:05b81afdc25d1ce43cb59647c9992559dc7487b1670ccab0426fc8b8f859e933"}, 1516 | {file = "SQLAlchemy-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7b07b83789997cf83ce9a5e7156a2b9a6cb54a4137add8ad95eff32f6746279b"}, 1517 | {file = "SQLAlchemy-2.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e49e9aefffe9c598a6ccf8d2dbb4556f4d93d0ae346b9d199b3712d24af0ce75"}, 1518 | {file = "SQLAlchemy-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a4e134eac68354cce40b35ccdbc91ff67ce0c791ea4aa81e021f2ee14bfefb"}, 1519 | {file = "SQLAlchemy-2.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db3e4db26c1a771d7b23a1031eaf351cfcaaa96d463ae900bb56c6a6f0585fbf"}, 1520 | {file = "SQLAlchemy-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:54b24a20cca275ada37ba40aa87dd257fda6e7da7f448d3282b6124d940f64d5"}, 1521 | {file = "SQLAlchemy-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:664ec164bc01ab66dfd19062ca7982a9ea12274596e17732908eb78621adc147"}, 1522 | {file = "SQLAlchemy-2.0.1-cp38-cp38-win32.whl", hash = "sha256:6dd8405bd1ffcbf11fda0e6b172e7e90044610de16325295efe92367551f666d"}, 1523 | {file = "SQLAlchemy-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:5bc451ee18776dcb6b2ac8c154db0536f75a2535f5da055179734f5e7f2e7b72"}, 1524 | {file = "SQLAlchemy-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:655e93fabd11bf53e6af44cee152b608d49ece4b4d9cc29328dd476faaa47c0c"}, 1525 | {file = "SQLAlchemy-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a5e1826a1ebbbbc26285a0304d7cafff4ec63cdae83fde89d5f2ec67f4444a44"}, 1526 | {file = "SQLAlchemy-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8770318683c8e08976633cec2c9711eb4279553ecbad1ca97f82c5b9174e0e76"}, 1527 | {file = "SQLAlchemy-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08c9169692722df8a2ef6c6ff1055e11563c990e9c74df9af62139a0c6397b8c"}, 1528 | {file = "SQLAlchemy-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c34c6b7975cb9e4848d4366d54a634bbced7b491a36029642c7e738a44b595a3"}, 1529 | {file = "SQLAlchemy-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31d019c60f4817b24c484d3110c7754cd2b8f7070057eddef5822994bf16da5a"}, 1530 | {file = "SQLAlchemy-2.0.1-cp39-cp39-win32.whl", hash = "sha256:c681d0f59c8ed12fd3f68d08d423354b1cc501220ddabc7a20b9ca8ed52b8f70"}, 1531 | {file = "SQLAlchemy-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:619784c399f5c4240b002e4dba30cfba15696274614da77846b69b2c9a74066b"}, 1532 | {file = "SQLAlchemy-2.0.1-py3-none-any.whl", hash = "sha256:f44c37e03cb941dd0db371a9f391cfb586c9966f436bf18b5492ee26f5ac6a5b"}, 1533 | {file = "SQLAlchemy-2.0.1.tar.gz", hash = "sha256:70d38432d75f6c95973f9713b30881e40a4e8d8ccfe8bbeb55466d8c737acc79"}, 1534 | ] 1535 | 1536 | [package.dependencies] 1537 | greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} 1538 | typing-extensions = ">=4.2.0" 1539 | 1540 | [package.extras] 1541 | aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] 1542 | aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] 1543 | asyncio = ["greenlet (!=0.4.17)"] 1544 | asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] 1545 | mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] 1546 | mssql = ["pyodbc"] 1547 | mssql-pymssql = ["pymssql"] 1548 | mssql-pyodbc = ["pyodbc"] 1549 | mypy = ["mypy (>=0.910)"] 1550 | mysql = ["mysqlclient (>=1.4.0)"] 1551 | mysql-connector = ["mysql-connector-python"] 1552 | oracle = ["cx-oracle (>=7)"] 1553 | oracle-oracledb = ["oracledb (>=1.0.1)"] 1554 | postgresql = ["psycopg2 (>=2.7)"] 1555 | postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] 1556 | postgresql-pg8000 = ["pg8000 (>=1.29.1)"] 1557 | postgresql-psycopg = ["psycopg (>=3.0.7)"] 1558 | postgresql-psycopg2binary = ["psycopg2-binary"] 1559 | postgresql-psycopg2cffi = ["psycopg2cffi"] 1560 | pymysql = ["pymysql"] 1561 | sqlcipher = ["sqlcipher3-binary"] 1562 | 1563 | [[package]] 1564 | name = "stevedore" 1565 | version = "4.1.1" 1566 | description = "Manage dynamic plugins for Python applications" 1567 | optional = false 1568 | python-versions = ">=3.8" 1569 | files = [ 1570 | {file = "stevedore-4.1.1-py3-none-any.whl", hash = "sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e"}, 1571 | {file = "stevedore-4.1.1.tar.gz", hash = "sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a"}, 1572 | ] 1573 | 1574 | [package.dependencies] 1575 | pbr = ">=2.0.0,<2.1.0 || >2.1.0" 1576 | 1577 | [[package]] 1578 | name = "toml" 1579 | version = "0.10.2" 1580 | description = "Python Library for Tom's Obvious, Minimal Language" 1581 | optional = false 1582 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 1583 | files = [ 1584 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1585 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1586 | ] 1587 | 1588 | [[package]] 1589 | name = "tomli" 1590 | version = "2.0.1" 1591 | description = "A lil' TOML parser" 1592 | optional = false 1593 | python-versions = ">=3.7" 1594 | files = [ 1595 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 1596 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 1597 | ] 1598 | 1599 | [[package]] 1600 | name = "typing-extensions" 1601 | version = "4.4.0" 1602 | description = "Backported and Experimental Type Hints for Python 3.7+" 1603 | optional = false 1604 | python-versions = ">=3.7" 1605 | files = [ 1606 | {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, 1607 | {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, 1608 | ] 1609 | 1610 | [[package]] 1611 | name = "urllib3" 1612 | version = "1.26.19" 1613 | description = "HTTP library with thread-safe connection pooling, file post, and more." 1614 | optional = false 1615 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 1616 | files = [ 1617 | {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, 1618 | {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, 1619 | ] 1620 | 1621 | [package.extras] 1622 | brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] 1623 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] 1624 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 1625 | 1626 | [[package]] 1627 | name = "virtualenv" 1628 | version = "20.17.1" 1629 | description = "Virtual Python Environment builder" 1630 | optional = false 1631 | python-versions = ">=3.6" 1632 | files = [ 1633 | {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, 1634 | {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, 1635 | ] 1636 | 1637 | [package.dependencies] 1638 | distlib = ">=0.3.6,<1" 1639 | filelock = ">=3.4.1,<4" 1640 | platformdirs = ">=2.4,<3" 1641 | 1642 | [package.extras] 1643 | docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] 1644 | testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] 1645 | 1646 | [[package]] 1647 | name = "zipp" 1648 | version = "3.19.1" 1649 | description = "Backport of pathlib-compatible object wrapper for zip files" 1650 | optional = false 1651 | python-versions = ">=3.8" 1652 | files = [ 1653 | {file = "zipp-3.19.1-py3-none-any.whl", hash = "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091"}, 1654 | {file = "zipp-3.19.1.tar.gz", hash = "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f"}, 1655 | ] 1656 | 1657 | [package.extras] 1658 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 1659 | test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] 1660 | 1661 | [metadata] 1662 | lock-version = "2.0" 1663 | python-versions = "^3.10" 1664 | content-hash = "9022bdf1f1b1950743409287258966bfc9d8ae062356fadbf3355e9802bb0f17" 1665 | --------------------------------------------------------------------------------