diff --git a/.env-example b/.env-example index 3da2865..16b8df1 100644 --- a/.env-example +++ b/.env-example @@ -1,4 +1,4 @@ -# For information about this variables check config.py +# For information about this variables check butterrobot/config.py SLACK_TOKEN=xxx TELEGRAM_TOKEN=xxx diff --git a/.github/workflows/black.yaml b/.github/workflows/black.yaml new file mode 100644 index 0000000..3d0810e --- /dev/null +++ b/.github/workflows/black.yaml @@ -0,0 +1,27 @@ +name: Black + +on: + push: + branches: [ master, stable ] + pull_request: + branches: [ master, stable ] + +jobs: + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install black + + - name: Black check + run: | + black --check butterrobot diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml new file mode 100644 index 0000000..8db9c98 --- /dev/null +++ b/.github/workflows/pytest.yaml @@ -0,0 +1,32 @@ +name: Pytest + +on: + push: + branches: [ master, stable ] + pull_request: + branches: [ master, stable ] + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --upgrade pip poetry + poetry install + + - name: Test with pytest + run: | + ls + poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib diff --git a/.gitignore b/.gitignore index 411e90d..309685e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__ *.cert .env-local test.py +.coverage # Distribution dist @@ -12,4 +13,7 @@ dist pip-wheel-metadata # Github Codespaces -pythonenv3.8 \ No newline at end of file +pythonenv3.8 + +# Butterrobot +*.sqlite* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..621aa22 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.2.3 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: flake8 + +- repo: https://github.com/asottile/seed-isort-config + rev: v1.9.2 + hooks: + - id: seed-isort-config +- repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.20 + hooks: + - id: isort + +- repo: https://github.com/ambv/black + rev: stable + hooks: + - id: black + language_version: python3 diff --git a/Dockerfile.dev b/Dockerfile.dev index 3e1a8fb..c1a4a16 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,20 +1,26 @@ -FROM alpine:3.11 +FROM docker.io/library/alpine:3.11 ENV PYTHON_VERSION=3.8.2-r1 ENV APP_PORT 8080 ENV BUILD_DIR /tmp/build +ENV APP_PATH /etc/butterrobot WORKDIR ${BUILD_DIR} COPY README.md ${BUILD_DIR}/README.md COPY poetry.lock ${BUILD_DIR}/poetry.lock COPY pyproject.toml ${BUILD_DIR}/pyproject.toml -COPY ./butterrobot ${BUILD_DIR}/butterrobot COPY ./butterrobot_plugins_contrib ${BUILD_DIR}/butterrobot_plugins_contrib +COPY ./butterrobot ${BUILD_DIR}/butterrobot RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \ pip3 install poetry && \ poetry build && \ pip3 install ${BUILD_DIR}/dist/butterrobot-*.tar.gz && \ - rm -rf ${BUILD_DIR} + rm -rf ${BUILD_DIR} && \ + mkdir ${APP_PATH} && \ + chown -R 1000:1000 ${APP_PATH} + +USER 1000 +WORKDIR ${APP_PATH} COPY ./docker/bin/start-server.sh /usr/local/bin/start-server CMD ["/usr/local/bin/start-server"] diff --git a/Makefile b/Makefile index 729de14..2791e1c 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,9 @@ podman@dev: make podman@tag-dev make podman@push-dev +test: + poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib + clean: rm -rf dist rm -rf butterrobot.egg-info diff --git a/README.md b/README.md index 24823b7..7fb78d6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # Butter Robot -![Build stable tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) -![Build latest tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) +| Stable | Master | +| --- | --- | +| ![Build stable tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) | ![Build latest tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) | +| ![Pytest](https://github.com/fmartingr/butterrobot/workflows/Pytest/badge.svg?branch=stable) | ![Pytest](https://github.com/fmartingr/butterrobot/workflows/Pytest/badge.svg?branch=master) | Python framework to create bots for several platforms. @@ -9,25 +11,9 @@ Python framework to create bots for several platforms. > What is my purpose? -## Supported platforms +## Documentation -| Name | Receive messages | Send messages | -| --------------- | ---------------- | ------------- | -| Slack (app) | Yes | Yes | -| Telegram | Yes | Yes | - -## Provided plugins - - -### Development - -- `!ping`: Say `!ping` to get response with time elapsed. - -### Fun and entertainment - - -- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) -- Dice: Put `!dice` and wathever roll you want to perform. +[Go to documentation](./docs) ## Installation @@ -44,12 +30,11 @@ $ python -m butterrobot ### Containers -The `fmartingr/butterrobot/butterrobot` container image is published on Github packages to -use with your favourite tool: +The `fmartingr/butterrobot/butterrobot` container image is published on Github packages to use with your favourite tool: ``` docker pull docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest -podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080 +podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080 ``` ## Contributing @@ -62,8 +47,7 @@ cd butterrobot poetry install ``` -Create a `.env-local` file with the required environment variables, -you have [an example file](.env-example). +Create a `.env-local` file with the required environment variables, you have [an example file](.env-example). ``` SLACK_TOKEN=xxx diff --git a/butterrobot/admin/__init__.py b/butterrobot/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/butterrobot/admin/blueprint.py b/butterrobot/admin/blueprint.py new file mode 100644 index 0000000..b9442fd --- /dev/null +++ b/butterrobot/admin/blueprint.py @@ -0,0 +1,156 @@ +import os.path +from functools import wraps + +import structlog +from flask import ( + Blueprint, + g, + flash, + request, + session, + url_for, + redirect, + render_template, +) + +from butterrobot.db import UserQuery, ChannelQuery, ChannelPluginQuery +from butterrobot.plugins import get_available_plugins + +admin = Blueprint("admin", __name__, url_prefix="/admin") +admin.template_folder = os.path.join(os.path.dirname(__name__), "templates") +logger = structlog.get_logger(__name__) + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + return redirect(url_for("admin.login_view", next=request.path)) + return f(*args, **kwargs) + + return decorated_function + + +@admin.before_app_request +def load_logged_in_user(): + user_id = session.get("user_id") + + if user_id is None: + g.user = None + else: + try: + user = UserQuery.get(id=user_id) + g.user = user + except UserQuery.NotFound: + g.user = None + + +@admin.route("/") +@login_required +def index_view(): + if not session.get("logged_in", False): + return redirect(url_for("admin.login_view")) + return redirect(url_for("admin.channel_list_view")) + + +@admin.route("/login", methods=["GET", "POST"]) +def login_view(): + error = None + if request.method == "POST": + user = UserQuery.check_credentials( + request.form["username"], request.form["password"] + ) + if not user: + flash("Incorrect credentials", category="danger") + else: + session["logged_in"] = True + session["user_id"] = user.id + flash("You were logged in", category="success") + _next = request.args.get("next", url_for("admin.index_view")) + return redirect(_next) + return render_template("login.j2", error=error) + + +@admin.route("/logout") +@login_required +def logout_view(): + session.clear() + flash("You were logged out", category="success") + return redirect(url_for("admin.index_view")) + + +@admin.route("/plugins") +@login_required +def plugin_list_view(): + return render_template("plugin_list.j2", plugins=get_available_plugins().values()) + + +@admin.route("/channels") +@login_required +def channel_list_view(): + return render_template("channel_list.j2", channels=ChannelQuery.all()) + + +@admin.route("/channels/", methods=["GET", "POST"]) +@login_required +def channel_detail_view(channel_id): + if request.method == "POST": + ChannelQuery.update( + channel_id, + enabled=request.form["enabled"] == "true", + ) + flash("Channel updated", "success") + + channel = ChannelQuery.get(channel_id) + return render_template( + "channel_detail.j2", channel=channel, plugins=get_available_plugins() + ) + + +@admin.route("/channel//delete", methods=["POST"]) +@login_required +def channel_delete_view(channel_id): + ChannelQuery.delete(channel_id) + flash("Channel removed", category="success") + return redirect(url_for("admin.channel_list_view")) + + +@admin.route("/channelplugins", methods=["GET", "POST"]) +@login_required +def channel_plugin_list_view(): + if request.method == "POST": + data = request.form + try: + ChannelPluginQuery.create( + data["channel_id"], data["plugin_id"], enabled=data["enabled"] == "y" + ) + flash(f"Plugin {data['plugin_id']} added to the channel", "success") + except ChannelPluginQuery.Duplicated: + flash( + f"Plugin {data['plugin_id']} is already present on the channel", "error" + ) + return redirect(request.headers.get("Referer")) + + channel_plugins = ChannelPluginQuery.all() + return render_template("channel_plugins_list.j2", channel_plugins=channel_plugins) + + +@admin.route("/channelplugins/", methods=["GET", "POST"]) +@login_required +def channel_plugin_detail_view(channel_plugin_id): + if request.method == "POST": + ChannelPluginQuery.update( + channel_plugin_id, + enabled=request.form["enabled"] == "true", + ) + flash("Plugin updated", category="success") + + return redirect(request.headers.get("Referer")) + + +@admin.route("/channelplugins//delete", methods=["POST"]) +@login_required +def channel_plugin_delete_view(channel_plugin_id): + ChannelPluginQuery.delete(channel_plugin_id) + flash("Plugin removed", category="success") + return redirect(request.headers.get("Referer")) diff --git a/butterrobot/admin/templates/_base.j2 b/butterrobot/admin/templates/_base.j2 new file mode 100644 index 0000000..d1db800 --- /dev/null +++ b/butterrobot/admin/templates/_base.j2 @@ -0,0 +1,122 @@ + + + + + + + ButterRobot Admin + + + + +
+
+ + {% if session.logged_in %} + + {% endif %} +
+ + {% for category, message in get_flashed_messages(with_categories=True) %} +
+
+
+

{{ message }}

+
+
+ {% endfor %} + +
+
+ {% block content %} + {% endblock %} +
+
+ +
+ + + diff --git a/butterrobot/admin/templates/channel_detail.j2 b/butterrobot/admin/templates/channel_detail.j2 new file mode 100644 index 0000000..409a230 --- /dev/null +++ b/butterrobot/admin/templates/channel_detail.j2 @@ -0,0 +1,140 @@ +{% extends "_base.j2" %} + +{% block content %} + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
ID{{ channel.id }}
Platform{{ channel.platform }}
Platform Channel ID{{ channel.platform_channel_id }}
RAW +
{{ channel.channel_raw }}
+
+
+
+
+
+
+
+

Plugins

+
+
+
+ + +

+

+
+ Enable plugin +
+
+ +
+
+ +
+
+

+
+
+ + + + + + + + + + + {% for channel_plugin in channel.plugins.values() %} + + + + + + {% else %} + + + + {% endfor %} + +
NameConfigurationActions
{{ plugins[channel_plugin.plugin_id].name }} +
{{ channel_plugin.config }}
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
No plugin is enabled on this channel
+
+
+
+
+
+{% endblock %} diff --git a/butterrobot/admin/templates/channel_list.j2 b/butterrobot/admin/templates/channel_list.j2 new file mode 100644 index 0000000..6bb0887 --- /dev/null +++ b/butterrobot/admin/templates/channel_list.j2 @@ -0,0 +1,45 @@ +{% extends "_base.j2" %} + +{% block content %} + + +
+
+ + + + + + + + + + + + + {% for channel in channels %} + + + + + + + + {% endfor %} + +
PlatformChannel nameChannel IDEnabled
{{ channel.platform }}{{ channel.channel_name }} + {{ channel.platform_channel_id }} + {{ channel.enabled }} + Edit +
+
+
+{% endblock %} diff --git a/butterrobot/admin/templates/channel_plugins_list.j2 b/butterrobot/admin/templates/channel_plugins_list.j2 new file mode 100644 index 0000000..2ee69bb --- /dev/null +++ b/butterrobot/admin/templates/channel_plugins_list.j2 @@ -0,0 +1,41 @@ +{% extends "_base.j2" %} + +{% block content %} + + +
+
+ + + + + + + + + + + + {% for channel_plugin in channel_plugins %} + + + + + + + {% endfor %} + +
IDChannel IDPlugin IDEnabled
{{ channel_plugin.id }}{{ channel_plugin.channel_id }} + {{ channel_plugin.plugin_id }} + {{ channel_plugin.enabled }}
+
+
+{% endblock %} diff --git a/butterrobot/admin/templates/index.j2 b/butterrobot/admin/templates/index.j2 new file mode 100644 index 0000000..70b55ea --- /dev/null +++ b/butterrobot/admin/templates/index.j2 @@ -0,0 +1,5 @@ +{% extends "_base.j2" %} + +{% block content %} + +{% endblock %} diff --git a/butterrobot/admin/templates/login.j2 b/butterrobot/admin/templates/login.j2 new file mode 100644 index 0000000..eb78117 --- /dev/null +++ b/butterrobot/admin/templates/login.j2 @@ -0,0 +1,32 @@ +{% extends "_base.j2" %} + +{% block content %} +
+ + {% if error %}

Error: {{ error }}{% endif %} +

+
+

Login

+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+{% endblock %} diff --git a/butterrobot/admin/templates/plugin_list.j2 b/butterrobot/admin/templates/plugin_list.j2 new file mode 100644 index 0000000..68532fb --- /dev/null +++ b/butterrobot/admin/templates/plugin_list.j2 @@ -0,0 +1,33 @@ +{% extends "_base.j2" %} + +{% block content %} + + +
+
+ + + + + + + + + {% for plugin in plugins %} + + + + {% endfor %} + +
Name
{{ plugin.name }}
+
+
+{% endblock %} diff --git a/butterrobot/app.py b/butterrobot/app.py index 3759dc8..22344c0 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -1,69 +1,35 @@ -import traceback +import asyncio -from flask import Flask, request import structlog +from flask import Flask, request import butterrobot.logging # noqa -from butterrobot.config import ENABLED_PLUGINS -from butterrobot.objects import Message -from butterrobot.plugins import get_available_plugins -from butterrobot.platforms import PLATFORMS -from butterrobot.platforms.base import Platform - +from butterrobot.http import ExternalProxyFix +from butterrobot.queue import q +from butterrobot.config import SECRET_KEY +from butterrobot.platforms import get_available_platforms +from butterrobot.admin.blueprint import admin as admin_bp +loop = asyncio.get_event_loop() logger = structlog.get_logger(__name__) app = Flask(__name__) -available_platforms = {} -plugins = get_available_plugins() -enabled_plugins = [ - plugin for plugin_name, plugin in plugins.items() if plugin_name in ENABLED_PLUGINS -] - - -def handle_message(platform: str, message: Message): - for plugin in enabled_plugins: - for response_message in plugin.on_message(message): - available_platforms[platform].methods.send_message(response_message) - - -@app.before_first_request -def init_platforms(): - for platform in PLATFORMS.values(): - logger.debug("Setting up", platform=platform.ID) - try: - platform.init(app=app) - available_platforms[platform.ID] = platform - logger.info("platform setup completed", platform=platform.ID) - except platform.PlatformInitError as error: - logger.error("Platform init error", error=error, platform=platform.ID) +app.config.update(SECRET_KEY=SECRET_KEY) +app.register_blueprint(admin_bp) +app.wsgi_app = ExternalProxyFix(app.wsgi_app) @app.route("//incoming", methods=["POST"]) @app.route("//incoming/", methods=["POST"]) def incoming_platform_message_view(platform, path=None): - if platform not in available_platforms: + if platform not in get_available_platforms(): return {"error": "Unknown platform"}, 400 - try: - message = available_platforms[platform].parse_incoming_message( - request=request - ) - except Platform.PlatformAuthResponse as response: - return response.data, response.status_code - except Exception as error: - logger.error( - "Error parsing message", - platform=platform, - error=error, - traceback=traceback.format_exc(), - ) - return {"error": str(error)}, 400 - - if not message or message.from_bot: - return {} - - # TODO: make with rq/dramatiq - handle_message(platform, message) + q.put( + { + "platform": platform, + "request": {"path": request.path, "json": request.get_json()}, + } + ) return {} diff --git a/butterrobot/config.py b/butterrobot/config.py index cf428ed..cefa366 100644 --- a/butterrobot/config.py +++ b/butterrobot/config.py @@ -3,12 +3,16 @@ import os # --- Butter Robot ----------------------------------------------------------------- DEBUG = os.environ.get("DEBUG", "n") == "y" -HOSTNAME = os.environ.get("BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network") +HOSTNAME = os.environ.get( + "BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network" +) LOG_LEVEL = os.environ.get("LOG_LEVEL", "ERROR") -ENABLED_PLUGINS = os.environ.get("ENABLED_PLUGINS", "contrib/dev/ping").split(",") +SECRET_KEY = os.environ.get("SECRET_KEY", "1234") +# --- DATABASE --------------------------------------------------------------------- +DATABASE_PATH = os.environ.get("DATABASE_PATH", "sqlite:///butterrobot.sqlite") # --- PLATFORMS --------------------------------------------------------------------- # --- diff --git a/butterrobot/db.py b/butterrobot/db.py new file mode 100644 index 0000000..fe29920 --- /dev/null +++ b/butterrobot/db.py @@ -0,0 +1,163 @@ +import hashlib +from typing import Union + +import dataset + +from butterrobot.config import SECRET_KEY, DATABASE_PATH +from butterrobot.objects import User, Channel, ChannelPlugin + +db = dataset.connect(DATABASE_PATH) + + +class Query: + class NotFound(Exception): + pass + + class Duplicated(Exception): + pass + + @classmethod + def all(cls): + """ + Iterate over all rows on a table. + """ + for row in db[cls.tablename].all(): + yield cls.obj(**row) + + @classmethod + def get(cls, **kwargs): + """ + Returns the object representation of an specific row in a table. + Allows retrieving object by multiple columns. + Raises `NotFound` error if query return no results. + """ + row = db[cls.tablename].find_one(**kwargs) + if not row: + raise cls.NotFound + return cls.obj(**row) + + @classmethod + def create(cls, **kwargs): + """ + Creates a new row in the table with the provided arguments. + Returns the row_id + TODO: Return obj? + """ + return db[cls.tablename].insert(kwargs) + + @classmethod + def exists(cls, **kwargs) -> bool: + """ + Check for the existence of a row with the provided columns. + """ + try: + cls.get(**kwargs) + except cls.NotFound: + return False + return True + + @classmethod + def update(cls, row_id, **fields): + fields.update({"id": row_id}) + return db[cls.tablename].update(fields, ("id",)) + + @classmethod + def delete(cls, id): + return db[cls.tablename].delete(id=id) + + +class UserQuery(Query): + tablename = "users" + obj = User + + @classmethod + def _hash_password(cls, password): + return hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), str.encode(SECRET_KEY), 100000 + ).hex() + + @classmethod + def check_credentials(cls, username, password) -> Union[User, "False"]: + user = db[cls.tablename].find_one(username=username) + if user: + hash_password = cls._hash_password(password) + if user["password"] == hash_password: + return cls.obj(**user) + return False + + @classmethod + def create(cls, **kwargs): + kwargs["password"] = cls._hash_password(kwargs["password"]) + return super().create(**kwargs) + + +class ChannelQuery(Query): + tablename = "channels" + obj = Channel + + @classmethod + def create(cls, platform, platform_channel_id, enabled=False, channel_raw={}): + params = { + "platform": platform, + "platform_channel_id": platform_channel_id, + "enabled": enabled, + "channel_raw": channel_raw, + } + super().create(**params) + return cls.obj(**params) + + @classmethod + def get(cls, _id): + channel = super().get(id=_id) + plugins = ChannelPluginQuery.get_from_channel_id(_id) + channel.plugins = {plugin.plugin_id: plugin for plugin in plugins} + return channel + + @classmethod + def get_by_platform(cls, platform, platform_channel_id): + result = db[cls.tablename].find_one( + platform=platform, platform_channel_id=platform_channel_id + ) + if not result: + raise cls.NotFound + + plugins = ChannelPluginQuery.get_from_channel_id(result["id"]) + + return cls.obj( + plugins={plugin.plugin_id: plugin for plugin in plugins}, **result + ) + + @classmethod + def delete(cls, _id): + ChannelPluginQuery.delete_by_channel(channel_id=_id) + super().delete(_id) + + +class ChannelPluginQuery(Query): + tablename = "channel_plugin" + obj = ChannelPlugin + + @classmethod + def create(cls, channel_id, plugin_id, enabled=False, config={}): + if cls.exists(channel_id=channel_id, plugin_id=plugin_id): + raise cls.Duplicated + + params = { + "channel_id": channel_id, + "plugin_id": plugin_id, + "enabled": enabled, + "config": config, + } + obj_id = super().create(**params) + return cls.obj(id=obj_id, **params) + + @classmethod + def get_from_channel_id(cls, channel_id): + yield from [ + cls.obj(**row) for row in db[cls.tablename].find(channel_id=channel_id) + ] + + @classmethod + def delete_by_channel(cls, channel_id): + channel_plugins = cls.get_from_channel_id(channel_id) + [cls.delete(item.id) for item in channel_plugins] diff --git a/butterrobot/http.py b/butterrobot/http.py new file mode 100644 index 0000000..9086155 --- /dev/null +++ b/butterrobot/http.py @@ -0,0 +1,15 @@ +class ExternalProxyFix(object): + """ + Custom proxy helper to get the external hostname from the `X-External-Host` header + used by one of the reverse proxies in front of this in production. + It does nothing if the header is not present. + """ + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + host = environ.get("HTTP_X_EXTERNAL_HOST", "") + if host: + environ["HTTP_HOST"] = host + return self.app(environ, start_response) diff --git a/butterrobot/lib/slack.py b/butterrobot/lib/slack.py index e0dbbd1..b8cc7d3 100644 --- a/butterrobot/lib/slack.py +++ b/butterrobot/lib/slack.py @@ -11,6 +11,7 @@ logger = structlog.get_logger() class SlackAPI: BASE_URL = "https://slack.com/api" + HEADERS = {"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"} class SlackError(Exception): pass @@ -18,6 +19,30 @@ class SlackAPI: class SlackClientError(Exception): pass + @classmethod + def get_conversations_info(cls, chat_id) -> dict: + params = {"channel": chat_id} + response = requests.get( + f"{cls.BASE_URL}/conversations.info", params=params, headers=cls.HEADERS, + ) + response_json = response.json() + if not response_json["ok"]: + raise cls.SlackClientError(response_json) + + return response_json["channel"] + + @classmethod + def get_user_info(cls, chat_id) -> dict: + params = {"user": chat_id} + response = requests.get( + f"{cls.BASE_URL}/users.info", params=params, headers=cls.HEADERS, + ) + response_json = response.json() + if not response_json["ok"]: + raise cls.SlackClientError(response_json) + + return response_json["user"] + @classmethod def send_message(cls, channel, message, thread: Optional[Text] = None): payload = { @@ -28,11 +53,9 @@ class SlackAPI: if thread: payload["thread_ts"] = thread - response = requestts.post( - f"{cls.BASE_URL}/chat.postMessage", - data=payload, - headers={"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"}, - ) + response = requests.post( + f"{cls.BASE_URL}/chat.postMessage", data=payload, headers=cls.HEADERS, + ) response_json = response.json() if not response_json["ok"]: raise cls.SlackClientError(response_json) diff --git a/butterrobot/lib/telegram.py b/butterrobot/lib/telegram.py index 2b9fb45..a10ecfa 100644 --- a/butterrobot/lib/telegram.py +++ b/butterrobot/lib/telegram.py @@ -51,8 +51,8 @@ class TelegramAPI: "disable_notification": disable_notification, "reply_to_message_id": reply_to_message_id, } - + response = requests.post(url, json=payload) response_json = response.json() if not response_json["ok"]: - raise cls.TelegramClientError(response_json) \ No newline at end of file + raise cls.TelegramClientError(response_json) diff --git a/butterrobot/logging.py b/butterrobot/logging.py index 4b30bd4..f31e5b8 100644 --- a/butterrobot/logging.py +++ b/butterrobot/logging.py @@ -14,7 +14,9 @@ structlog.configure( structlog.processors.StackInfoRenderer(), structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"), structlog.processors.format_exc_info, - structlog.dev.ConsoleRenderer() if DEBUG else structlog.processors.JSONRenderer(), + structlog.dev.ConsoleRenderer() + if DEBUG + else structlog.processors.JSONRenderer(), ], context_class=dict, logger_factory=structlog.stdlib.LoggerFactory(), diff --git a/butterrobot/objects.py b/butterrobot/objects.py index 55051c7..215924b 100644 --- a/butterrobot/objects.py +++ b/butterrobot/objects.py @@ -1,15 +1,61 @@ from datetime import datetime from dataclasses import dataclass, field -from typing import Text, Optional +from typing import Text, Optional, Dict + +import structlog + + +logger = structlog.get_logger(__name__) + + +@dataclass +class ChannelPlugin: + id: int + channel_id: int + plugin_id: str + enabled: bool = False + config: dict = field(default_factory=dict) + + +@dataclass +class Channel: + platform: str + platform_channel_id: str + channel_raw: dict + enabled: bool = False + id: Optional[int] = None + plugins: Dict[str, ChannelPlugin] = field(default_factory=dict) + + def has_enabled_plugin(self, plugin_id): + if plugin_id not in self.plugins: + logger.debug("No enabled!", plugin_id=plugin_id, plugins=self.plugins) + return False + + return self.plugins[plugin_id].enabled + + @property + def channel_name(self): + from butterrobot.platforms import PLATFORMS + + return PLATFORMS[self.platform].parse_channel_name_from_raw(self.channel_raw) @dataclass class Message: text: Text chat: Text + # TODO: Move chat references to `.channel.platform_channel_id` + channel: Optional[Channel] = None author: Text = None from_bot: bool = False date: Optional[datetime] = None id: Optional[Text] = None reply_to: Optional[Text] = None raw: dict = field(default_factory=dict) + + +@dataclass +class User: + id: int + username: Text + password: Text diff --git a/butterrobot/platforms/__init__.py b/butterrobot/platforms/__init__.py index c63b65e..dad1360 100644 --- a/butterrobot/platforms/__init__.py +++ b/butterrobot/platforms/__init__.py @@ -1,6 +1,30 @@ +from functools import lru_cache + +import structlog + from butterrobot.platforms.slack import SlackPlatform from butterrobot.platforms.telegram import TelegramPlatform from butterrobot.platforms.debug import DebugPlatform -PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform, DebugPlatform)} +logger = structlog.get_logger(__name__) +PLATFORMS = { + platform.ID: platform + for platform in (SlackPlatform, TelegramPlatform, DebugPlatform) +} + + +@lru_cache +def get_available_platforms(): + from butterrobot.platforms import PLATFORMS + + available_platforms = {} + for platform in PLATFORMS.values(): + logger.debug("Setting up", platform=platform.ID) + try: + platform.init(app=None) + available_platforms[platform.ID] = platform + logger.info("platform setup completed", platform=platform.ID) + except platform.PlatformInitError as error: + logger.error("Platform init error", error=error, platform=platform.ID) + return available_platforms diff --git a/butterrobot/platforms/base.py b/butterrobot/platforms/base.py index 1201739..a5d778e 100644 --- a/butterrobot/platforms/base.py +++ b/butterrobot/platforms/base.py @@ -1,4 +1,4 @@ -from abc import abstractclassmethod +from abc import abstractmethod from dataclasses import dataclass @@ -17,19 +17,51 @@ class Platform: """ Used when the platform needs to make a response right away instead of async. """ + data: dict status_code: int = 200 @classmethod def init(cls, app): + """ + Initialises the platform. + + Used at the application launch to prepare anything required for + the platform to work.. + + It receives the flask application via parameter in case the platform + requires for custom webservice endpoints or configuration. + """ + pass + + @classmethod + @abstractmethod + def parse_incoming_message(cls, request): + """ + Parses the incoming request and returns a :class:`butterrobot.objects.Message` instance. + """ + pass + + @classmethod + @abstractmethod + def parse_channel_name_from_raw(cls, channel_raw) -> str: + """ + Extracts the Channel name from :class:`butterrobot.objects.Channel.channel_raw`. + """ + pass + + @classmethod + @abstractmethod + def parse_channel_from_message(cls, channel_raw): + """ + Extracts the Channel raw data from the message received in the incoming webhook. + """ pass class PlatformMethods: - @abstractclassmethod + @classmethod + @abstractmethod def send_message(cls, message): - pass - - @abstractclassmethod - def reply_message(cls, message, reply_to): + """Method used to send a message via the platform""" pass diff --git a/butterrobot/platforms/debug.py b/butterrobot/platforms/debug.py index 6faacfe..9982011 100644 --- a/butterrobot/platforms/debug.py +++ b/butterrobot/platforms/debug.py @@ -4,7 +4,7 @@ from datetime import datetime import structlog from butterrobot.platforms.base import Platform, PlatformMethods -from butterrobot.objects import Message +from butterrobot.objects import Message, Channel logger = structlog.get_logger(__name__) @@ -25,7 +25,7 @@ class DebugPlatform(Platform): @classmethod def parse_incoming_message(cls, request): - request_data = request.get_json() + request_data = request["json"] logger.debug("Parsing message", data=request_data, platform=cls.ID) return Message( @@ -35,5 +35,10 @@ class DebugPlatform(Platform): from_bot=bool(request_data.get("from_bot", False)), author=request_data.get("author", "Debug author"), chat=request_data.get("chat", "Debug chat ID"), + channel=Channel( + platform=cls.ID, + platform_channel_id=request_data.get("chat"), + channel_raw={}, + ), raw={}, ) diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py index 8a77a01..aa1b133 100644 --- a/butterrobot/platforms/slack.py +++ b/butterrobot/platforms/slack.py @@ -4,7 +4,7 @@ import structlog from butterrobot.platforms.base import Platform, PlatformMethods from butterrobot.config import SLACK_TOKEN, SLACK_BOT_OAUTH_ACCESS_TOKEN -from butterrobot.objects import Message +from butterrobot.objects import Message, Channel from butterrobot.lib.slack import SlackAPI @@ -41,9 +41,27 @@ class SlackPlatform(Platform): logger.error("Missing token. platform not enabled.", platform=cls.ID) return + @classmethod + def parse_channel_name_from_raw(cls, channel_raw): + return channel_raw["name"] + + @classmethod + def parse_channel_from_message(cls, message): + # Call different APIs for a channel or DM + if message["event"]["channel_type"] == "im": + chat_raw = SlackAPI.get_user_info(message["event"]["user"]) + else: + chat_raw = SlackAPI.get_conversations_info(message["event"]["channel"]) + + return Channel( + platform=cls.ID, + platform_channel_id=message["event"]["channel"], + channel_raw=chat_raw, + ) + @classmethod def parse_incoming_message(cls, request): - data = request.get_json() + data = request["json"] # Auth if data.get("token") != SLACK_TOKEN: @@ -58,16 +76,30 @@ class SlackPlatform(Platform): logger.debug("Discarding message", data=data) return - if data["event"]["type"] != "message": + logger.debug("Parsing message", platform=cls.ID, data=data) + + if data["event"]["type"] not in ("message", "message.groups"): return - logger.debug("Parsing message", platform=cls.ID, data=data) - return Message( + # Surprisingly, this *can* happen. + if "text" not in data["event"]: + return + + message = Message( id=data["event"].get("thread_ts", data["event"]["ts"]), - author=data["event"]["user"], + author=data["event"].get("user"), from_bot="bot_id" in data["event"], date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))), text=data["event"]["text"], chat=data["event"]["channel"], + channel=cls.parse_channel_from_message(data), raw=data, ) + + logger.info( + "New message", + platform=message.channel.platform, + channel=cls.parse_channel_name_from_raw(message.channel.channel_raw), + ) + + return message diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py index 13583fe..ddf7607 100644 --- a/butterrobot/platforms/telegram.py +++ b/butterrobot/platforms/telegram.py @@ -5,7 +5,7 @@ import structlog from butterrobot.platforms.base import Platform, PlatformMethods from butterrobot.config import TELEGRAM_TOKEN, HOSTNAME from butterrobot.lib.telegram import TelegramAPI -from butterrobot.objects import Message +from butterrobot.objects import Message, Channel logger = structlog.get_logger(__name__) @@ -46,23 +46,42 @@ class TelegramPlatform(Platform): logger.error(f"Error setting Telegram webhook: {error}", platform=cls.ID) raise Platform.PlatformInitError() + @classmethod + def parse_channel_name_from_raw(cls, channel_raw): + if channel_raw["id"] < 0: + return channel_raw["title"] + else: + if channel_raw["username"]: + return f"@{channel_raw['username']}" + return f"{channel_raw['first_name']} {channel_raw['last_name']}" + + @classmethod + def parse_channel_from_message(cls, channel_raw): + return Channel( + platform=cls.ID, + platform_channel_id=channel_raw["id"], + channel_raw=channel_raw, + ) + @classmethod def parse_incoming_message(cls, request): - token = request.path.split("/")[-1] + token = request["path"].split("/")[-1] if token != TELEGRAM_TOKEN: raise cls.PlatformAuthError("Authentication error") - request_data = request.get_json() - logger.debug("Parsing message", data=request_data, platform=cls.ID) + logger.debug("Parsing message", data=request["json"], platform=cls.ID) - if "text" in request_data["message"]: + if "text" in request["json"]["message"]: # Ignore all messages but text messages return Message( - id=request_data["message"]["message_id"], - date=datetime.fromtimestamp(request_data["message"]["date"]), - text=str(request_data["message"]["text"]), - from_bot=request_data["message"]["from"]["is_bot"], - author=request_data["message"]["from"]["id"], - chat=str(request_data["message"]["chat"]["id"]), - raw=request_data, + id=request["json"]["message"]["message_id"], + date=datetime.fromtimestamp(request["json"]["message"]["date"]), + text=str(request["json"]["message"]["text"]), + from_bot=request["json"]["message"]["from"]["is_bot"], + author=request["json"]["message"]["from"]["id"], + chat=str(request["json"]["message"]["chat"]["id"]), + channel=cls.parse_channel_from_message( + request["json"]["message"]["chat"] + ), + raw=request["json"], ) diff --git a/butterrobot/plugins.py b/butterrobot/plugins.py index 514c69e..0ee3b55 100644 --- a/butterrobot/plugins.py +++ b/butterrobot/plugins.py @@ -1,24 +1,54 @@ import traceback import pkg_resources from abc import abstractclassmethod +from functools import lru_cache +from typing import Optional, Dict import structlog from butterrobot.objects import Message + logger = structlog.get_logger(__name__) class Plugin: + """ + Base Plugin class. + + All attributes are required except for `requires_config`. + """ + + id: str + name: str + help: str + requires_config: bool = False + @abstractclassmethod - def on_message(cls, message: Message): + def on_message(cls, message: Message, channel_config: Optional[Dict] = None): + """ + Function called for each message received on the chat. + + It should exit as soon as possible (usually checking for a keyword or something) + similar just at the start. + + If the plugin needs to be executed (keyword matches), keep it as fast as possible + as this currently blocks the execution of the rest of the plugins on the channel + until this does not finish. + TODO: Update this once we go proper async plugin/message integration + + In case something needs to be answered to the channel, you can `yield` a `Message` + instance and it will be relayed using the appropriate provider. + """ pass +@lru_cache def get_available_plugins(): - """Retrieves every available plugin""" + """ + Retrieves every available auto discovered plugin + """ plugins = {} - logger.debug("Loading plugins") for ep in pkg_resources.iter_entry_points("butterrobot.plugins"): try: plugin_cls = ep.load() @@ -34,5 +64,4 @@ def get_available_plugins(): module=ep.module_name, ) - logger.info(f"Plugins loaded", plugins=list(plugins.keys())) return plugins diff --git a/butterrobot/queue.py b/butterrobot/queue.py new file mode 100644 index 0000000..82f99a7 --- /dev/null +++ b/butterrobot/queue.py @@ -0,0 +1,64 @@ +import threading +import traceback +import queue + +import structlog + +from butterrobot.db import ChannelQuery +from butterrobot.platforms import get_available_platforms +from butterrobot.platforms.base import Platform +from butterrobot.plugins import get_available_plugins + +logger = structlog.get_logger(__name__) +q = queue.Queue() + + +def handle_message(platform: str, request: dict): + try: + message = get_available_platforms()[platform].parse_incoming_message( + request=request + ) + except Platform.PlatformAuthResponse as response: + return response.data, response.status_code + except Exception as error: + logger.error( + "Error parsing message", + platform=platform, + error=error, + traceback=traceback.format_exc(), + ) + return + + if not message or message.from_bot: + return + + try: + channel = ChannelQuery.get_by_platform(platform, message.chat) + except ChannelQuery.NotFound: + # If channel is still not present on the database, create it (defaults to disabled) + channel = ChannelQuery.create( + platform, message.chat, channel_raw=message.channel.channel_raw + ) + + if not channel.enabled: + return + + for plugin_id, channel_plugin in channel.plugins.items(): + if not channel.has_enabled_plugin(plugin_id): + continue + + for response_message in get_available_plugins()[plugin_id].on_message( + message, plugin_config=channel_plugin.config + ): + get_available_platforms()[platform].methods.send_message(response_message) + + +def worker_thread(): + while True: + item = q.get() + handle_message(item["platform"], item["request"]) + q.task_done() + + +# turn-on the worker thread +worker = threading.Thread(target=worker_thread, daemon=True).start() diff --git a/butterrobot_plugins_contrib/dev.py b/butterrobot_plugins_contrib/dev.py index c4641cb..c03f6c7 100644 --- a/butterrobot_plugins_contrib/dev.py +++ b/butterrobot_plugins_contrib/dev.py @@ -5,10 +5,11 @@ from butterrobot.objects import Message class PingPlugin(Plugin): - id = "contrib/dev/ping" + name = "Ping command" + id = "contrib.dev.ping" @classmethod - def on_message(cls, message): + def on_message(cls, message, **kwargs): if message.text == "!ping": delta = datetime.now() - message.date delta_ms = delta.seconds * 1000 + delta.microseconds / 1000 diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py index 33b69f6..4a5b3bf 100644 --- a/butterrobot_plugins_contrib/fun.py +++ b/butterrobot_plugins_contrib/fun.py @@ -1,34 +1,51 @@ import random import dice +import structlog from butterrobot.plugins import Plugin from butterrobot.objects import Message +logger = structlog.get_logger(__name__) + + class LoquitoPlugin(Plugin): - id = "contrib/fun/loquito" + name = "Loquito reply" + id = "contrib.fun.loquito" @classmethod - def on_message(cls, message): + def on_message(cls, message, **kwargs): if "lo quito" in message.text.lower(): - yield Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",) + yield Message( + chat=message.chat, reply_to=message.id, text="Loquito tu.", + ) class DicePlugin(Plugin): - id = "contrib/fun/dice" + name = "Dice command" + id = "contrib.fun.dice" + DEFAULT_FORMULA = "1d20" @classmethod - def on_message(cls, message: Message): + def on_message(cls, message: Message, **kwargs): if message.text.startswith("!dice"): - roll = int(dice.roll(message.text.replace("!dice ", ""))) + dice_formula = message.text.replace("!dice", "").strip() + if not dice_formula: + dice_formula = cls.DEFAULT_FORMULA + roll = int(dice.roll(dice_formula)) yield Message(chat=message.chat, reply_to=message.id, text=roll) class CoinPlugin(Plugin): - id = "contrib/fun/coin" + name = "Coin command" + id = "contrib.fun.coin" @classmethod - def on_message(cls, message: Message): + def on_message(cls, message: Message, **kwargs): if message.text.startswith("!coin"): - yield Message(chat=message.chat, reply_to=message.id, text=random.choice(("heads", "tails"))) + yield Message( + chat=message.chat, + reply_to=message.id, + text=random.choice(("heads", "tails")), + ) diff --git a/docker/Dockerfile b/docker/Dockerfile index 54c2ede..7c95591 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,12 +2,15 @@ FROM alpine:3.11 ENV PYTHON_VERSION=3.8.2-r1 ENV APP_PORT 8080 -ENV BUTTERROBOT_VERSION 0.0.2a4 +ENV BUTTERROBOT_VERSION 0.0.3 ENV EXTRA_DEPENDENCIES "" +ENV APP_PATH /etc/butterrobot COPY bin/start-server.sh /usr/local/bin/start-server RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \ - pip3 install butterrobot==${BUTTERROBOT_VERSION} ${EXTRA_DEPENDENCIES} + pip3 install butterrobot==${BUTTERROBOT_VERSION} ${EXTRA_DEPENDENCIES} && \ + mkdir ${APP_PATH} && \ + chown -R 1000:1000 ${APP_PATH} USER 1000 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e1e0ef4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Butterrobot Documentation + +## Index +- [Contributing](./contributing.md) +- [Platforms](./platforms.md) +- Plugins + - [Creating a Plugin](./creating-a-plugin.md) + - [Provided plugins](./plugins.md) diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ae11cef --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,23 @@ +## Contributing + +To run the project locally you will need [poetry](https://python-poetry.org/). + +``` +git clone git@github.com:fmartingr/butterrobot.git +cd butterrobot +make setup +``` + +Create a `.env-local` file with the required environment variables, you have [an example file](.env-example). + +``` +SLACK_TOKEN=xxx +TELEGRAM_TOKEN=xxx +... +``` + +And then you can run it directly with poetry: + +``` +poetry run python -m butterrobot +``` diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md new file mode 100644 index 0000000..0f4cee6 --- /dev/null +++ b/docs/creating-a-plugin.md @@ -0,0 +1,37 @@ +# Creating a Plugin + +## Example + +This simple "Marco Polo" plugin will answer _Polo_ to the user that say _Marco_: + +``` python +# mypackage/plugins.py +from butterrobot.plugins import Plugin +from butterrobot.objects import Message + + +class PingPlugin(Plugin): + name = "Marco/Polo" + id = "test.marco" + + @classmethod + def on_message(cls, message, **kwargs): + if message.text == "Marco": + yield Message( + chat=message.chat, reply_to=message.id, text=f"polo", + ) +``` + +``` python +# setup.py +# ... +entrypoints = { + "test.marco" = "mypackage.plugins:MarcoPlugin" +} + +setup( + # ... + entry_points=entrypoints, + # ... +) +``` diff --git a/docs/platforms.md b/docs/platforms.md new file mode 100644 index 0000000..0acbbe9 --- /dev/null +++ b/docs/platforms.md @@ -0,0 +1,8 @@ +## Supported platforms + +TODO: Create better actions matrix + +| Name | Receive messages | Send messages | +| --------------- | ---------------- | ------------- | +| Slack (app) | Yes | Yes | +| Telegram | Yes | Yes | diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..e4fcd29 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,11 @@ +## Provided plugins + +### Development + +- `!ping`: Say `!ping` to get response with time elapsed. + +### Fun and entertainment + + +- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) +- Dice: Put `!dice` and wathever roll you want to perform. diff --git a/poetry.lock b/poetry.lock index 48eba86..0d962d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,50 +1,78 @@ [[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -name = "appdirs" -optional = false -python-versions = "*" -version = "1.4.4" - -[[package]] -category = "dev" -description = "Disable App Nap on OS X 10.9" -marker = "python_version >= \"3.4\" and sys_platform == \"darwin\"" -name = "appnope" -optional = false -python-versions = "*" -version = "0.1.0" - -[[package]] -category = "dev" -description = "Classes Without Boilerplate" -name = "attrs" +name = "alembic" +version = "1.4.3" +description = "A database migration tool for SQLAlchemy." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.2.0" -[package.extras] -dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +[package.dependencies] +Mako = "*" +python-dateutil = "*" +python-editor = ">=0.3" +SQLAlchemy = ">=1.1.0" [[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" -description = "Specifications for callback functions passed in to an API" -marker = "python_version >= \"3.4\"" -name = "backcall" optional = false python-versions = "*" -version = "0.2.0" [[package]] +name = "appnope" +version = "0.1.2" +description = "Disable App Nap on macOS >= 10.9" category = "dev" -description = "The uncompromising code formatter." +optional = false +python-versions = "*" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "20.3.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "banal" +version = "1.0.1" +description = "Commons of banal micro-functions for Python." +category = "main" +optional = false +python-versions = "*" + +[[package]] name = "black" +version = "19.10b0" +description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.6" -version = "19.10b0" [package.dependencies] appdirs = "*" @@ -59,96 +87,143 @@ typed-ast = ">=1.4.0" d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." name = "certifi" +version = "2020.12.5" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = "*" -version = "2020.6.20" - -[[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" -optional = false -python-versions = "*" -version = "3.0.4" - -[[package]] -category = "main" -description = "Composable command line interface toolkit" -name = "click" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.2" - -[[package]] -category = "main" -description = "Cross-platform colored terminal text." -name = "colorama" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" [[package]] +name = "cfgv" +version = "3.2.0" +description = "Validate configuration and produce human readable error messages." category = "dev" -description = "Decorators for Humans" -marker = "python_version >= \"3.4\"" +optional = false +python-versions = ">=3.6.1" + +[[package]] +name = "chardet" +version = "3.0.4" +description = "Universal encoding detector for Python 2 and 3" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "5.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +toml = ["toml"] + +[[package]] +name = "dataset" +version = "1.4.1" +description = "Toolkit for Python-based database access." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +alembic = ">=0.6.2" +banal = ">=1.0.1" +sqlalchemy = ">=1.3.2" + +[package.extras] +dev = ["pip", "nose", "wheel", "flake8", "coverage", "psycopg2-binary", "pymysql", "cryptography"] + +[[package]] name = "decorator" +version = "4.4.2" +description = "Decorators for Humans" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" -version = "4.4.2" [[package]] -category = "main" -description = "A library for parsing and evaluating dice notation" name = "dice" +version = "3.1.1" +description = "A library for parsing and evaluating dice notation" +category = "main" optional = false python-versions = "*" -version = "3.1.0" [package.dependencies] docopt = ">=0.6.1" pyparsing = ">=2.4.1" [[package]] -category = "main" -description = "Pythonic argument parser, that will make you smile" -name = "docopt" +name = "distlib" +version = "0.3.1" +description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" -version = "0.6.2" [[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "filelock" +version = "3.0.12" +description = "A platform independent file lock." category = "dev" -description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = "*" + +[[package]] name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "3.8.3" [package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.6.0a1,<2.7.0" pyflakes = ">=2.2.0,<2.3.0" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = "*" - [[package]] -category = "main" -description = "A simple framework for building complex web applications." name = "flask" +version = "1.1.2" +description = "A simple framework for building complex web applications." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.1.2" [package.dependencies] -Jinja2 = ">=2.10.1" -Werkzeug = ">=0.15" click = ">=5.1" itsdangerous = ">=0.24" +Jinja2 = ">=2.10.1" +Werkzeug = ">=0.15" [package.extras] dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] @@ -156,64 +231,76 @@ docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx- dotenv = ["python-dotenv"] [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" -name = "idna" +name = "identify" +version = "1.5.13" +description = "File identification library for Python" +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.10" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.extras] +license = ["editdistance"] [[package]] -category = "dev" -description = "Read metadata from Python packages" -marker = "python_version < \"3.8\"" -name = "importlib-metadata" +name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "importlib-metadata" +version = "3.1.1" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" category = "dev" -description = "IPython-enabled pdb" +optional = false +python-versions = "*" + +[[package]] name = "ipdb" +version = "0.13.4" +description = "IPython-enabled pdb" +category = "dev" optional = false python-versions = ">=2.7" -version = "0.13.3" [package.dependencies] -setuptools = "*" - -[package.dependencies.ipython] -python = ">=3.4" -version = ">=5.1.0" +ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} [[package]] -category = "dev" -description = "IPython: Productive Interactive Computing" -marker = "python_version >= \"3.4\"" name = "ipython" +version = "7.19.0" +description = "IPython: Productive Interactive Computing" +category = "dev" optional = false python-versions = ">=3.7" -version = "7.18.1" [package.dependencies] -appnope = "*" +appnope = {version = "*", markers = "sys_platform == \"darwin\""} backcall = "*" -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" jedi = ">=0.10" -pexpect = ">4.3" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} pickleshare = "*" prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" pygments = "*" -setuptools = ">=18.5" traitlets = ">=4.2" [package.extras] @@ -228,21 +315,20 @@ qtconsole = ["qtconsole"] test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"] [[package]] -category = "dev" -description = "Vestigial utilities from IPython" -marker = "python_version >= \"3.4\"" name = "ipython-genutils" +version = "0.2.0" +description = "Vestigial utilities from IPython" +category = "dev" optional = false python-versions = "*" -version = "0.2.0" [[package]] -category = "dev" -description = "A Python utility / library to sort Python imports." name = "isort" +version = "4.3.21" +description = "A Python utility / library to sort Python imports." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "4.3.21" [package.extras] pipfile = ["pipreqs", "requirementslib"] @@ -251,36 +337,35 @@ requirements = ["pipreqs", "pip-api"] xdg_home = ["appdirs (>=1.4.0)"] [[package]] -category = "main" -description = "Various helpers to pass data to untrusted environments and back." name = "itsdangerous" +version = "1.1.0" +description = "Various helpers to pass data to untrusted environments and back." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.1.0" [[package]] -category = "dev" -description = "An autocompletion tool for Python that can be used for text editors." -marker = "python_version >= \"3.4\"" name = "jedi" +version = "0.17.2" +description = "An autocompletion tool for Python that can be used for text editors." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.17.2" [package.dependencies] parso = ">=0.7.0,<0.8.0" [package.extras] -qa = ["flake8 (3.7.9)"] +qa = ["flake8 (==3.7.9)"] testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] [[package]] -category = "main" -description = "A very fast and expressive template engine." name = "jinja2" +version = "2.11.2" +description = "A very fast and expressive template engine." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.2" [package.dependencies] MarkupSafe = ">=0.23" @@ -289,168 +374,320 @@ MarkupSafe = ">=0.23" i18n = ["Babel (>=0.8)"] [[package]] +name = "mako" +version = "1.1.3" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." category = "main" -description = "Safely add untrusted strings to HTML/XML markup." -name = "markupsafe" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" - -[[package]] -category = "dev" -description = "McCabe checker, plugin for flake8" -name = "mccabe" -optional = false -python-versions = "*" -version = "0.6.1" - -[[package]] -category = "dev" -description = "A Python Parser" -marker = "python_version >= \"3.4\"" -name = "parso" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["babel"] +lingua = ["lingua"] + +[[package]] +name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "nodeenv" +version = "1.5.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "20.7" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" + +[[package]] +name = "parso" version = "0.7.1" +description = "A Python Parser" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] testing = ["docopt", "pytest (>=3.0.7)"] [[package]] -category = "dev" -description = "Utility library for gitignore style pattern matching of file paths." name = "pathspec" +version = "0.8.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.8.0" [[package]] -category = "dev" -description = "Pexpect allows easy control of interactive console applications." -marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" optional = false python-versions = "*" -version = "4.8.0" [package.dependencies] ptyprocess = ">=0.5" [[package]] -category = "dev" -description = "Tiny 'shelve'-like database with concurrency support" -marker = "python_version >= \"3.4\"" name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" optional = false python-versions = "*" -version = "0.7.5" [[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "pre-commit" +version = "2.10.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +toml = "*" +virtualenv = ">=20.0.8" + +[[package]] +name = "prompt-toolkit" +version = "3.0.8" +description = "Library for building powerful interactive command lines in Python" category = "dev" -description = "Library for building powerful interactive command lines in Python" -marker = "python_version >= \"3.4\"" -name = "prompt-toolkit" optional = false python-versions = ">=3.6.1" -version = "3.0.7" [package.dependencies] wcwidth = "*" [[package]] -category = "dev" -description = "Run a subprocess in a pseudo terminal" -marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" name = "ptyprocess" +version = "0.6.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" optional = false python-versions = "*" -version = "0.6.0" [[package]] +name = "py" +version = "1.9.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" -description = "Python style guide checker" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] name = "pycodestyle" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.6.0" - -[[package]] +description = "Python style guide checker" category = "dev" -description = "passive checker of Python programs" -name = "pyflakes" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.2.0" [[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" category = "dev" -description = "Pygments is a syntax highlighting package written in Python." -marker = "python_version >= \"3.4\"" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] name = "pygments" +version = "2.7.3" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" optional = false python-versions = ">=3.5" -version = "2.7.1" [[package]] -category = "main" -description = "Python parsing module" name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" [[package]] +name = "pytest" +version = "6.1.2" +description = "pytest: simple powerful testing with Python" category = "dev" -description = "Alternative regular expression module, to replace re." -name = "regex" optional = false -python-versions = "*" -version = "2020.7.14" +python-versions = ">=3.5" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=17.4.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.8.2" +toml = "*" + +[package.extras] +checkqa_mypy = ["mypy (==0.780)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "main" -description = "Python HTTP for Humans." -name = "requests" +name = "pytest-cov" +version = "2.10.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-dateutil" +version = "2.8.1" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-editor" +version = "1.0.4" +description = "Programmatically open an editor, capture the result." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pyyaml" +version = "5.4.1" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[[package]] +name = "regex" +version = "2020.11.13" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.25.0" +description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.24.0" [package.dependencies] certifi = ">=2017.4.17" chardet = ">=3.0.2,<4" idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +urllib3 = ">=1.21.1,<1.27" [package.extras] security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] -category = "dev" -description = "a python refactoring library..." name = "rope" +version = "0.16.0" +description = "a python refactoring library..." +category = "dev" optional = false python-versions = "*" -version = "0.16.0" [package.extras] dev = ["pytest"] [[package]] -category = "main" -description = "Python 2 and 3 compatibility utilities" name = "six" +version = "1.15.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.15.0" [[package]] +name = "sqlalchemy" +version = "1.3.20" +description = "Database Abstraction Library" category = "main" -description = "Structured Logging for Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +mssql = ["pyodbc"] +mssql_pymssql = ["pymssql"] +mssql_pyodbc = ["pyodbc"] +mysql = ["mysqlclient"] +oracle = ["cx-oracle"] +postgresql = ["psycopg2"] +postgresql_pg8000 = ["pg8000"] +postgresql_psycopg2binary = ["psycopg2-binary"] +postgresql_psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql"] + +[[package]] name = "structlog" +version = "20.1.0" +description = "Structured Logging for Python" +category = "main" optional = false python-versions = "*" -version = "20.1.0" [package.dependencies] six = "*" @@ -462,21 +699,20 @@ docs = ["sphinx", "twisted"] tests = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "python-rapidjson", "pytest-asyncio"] [[package]] -category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" optional = false -python-versions = "*" -version = "0.10.1" +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] -category = "dev" -description = "Traitlets Python configuration system" -marker = "python_version >= \"3.4\"" name = "traitlets" +version = "5.0.5" +description = "Traitlets Python configuration system" +category = "dev" optional = false python-versions = ">=3.7" -version = "5.0.4" [package.dependencies] ipython-genutils = "*" @@ -485,101 +721,134 @@ ipython-genutils = "*" test = ["pytest"] [[package]] -category = "dev" -description = "a fork of Python 2 and 3 ast modules with type comment support" name = "typed-ast" +version = "1.4.1" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" optional = false python-versions = "*" -version = "1.4.1" [[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" +version = "1.26.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.11" [package.extras] brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "virtualenv" +version = "20.4.2" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.1,<1" +filelock = ">=3.0.0,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +six = ">=1.9.0,<2" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] [[package]] -category = "main" -description = "Waitress WSGI server" name = "waitress" +version = "1.4.4" +description = "Waitress WSGI server" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.4.4" [package.extras] docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.9)"] testing = ["pytest", "pytest-cover", "coverage (>=5.0)"] [[package]] -category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" -marker = "python_version >= \"3.4\"" name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" optional = false python-versions = "*" -version = "0.2.5" [[package]] -category = "main" -description = "The comprehensive WSGI web application library." name = "werkzeug" +version = "1.0.1" +description = "The comprehensive WSGI web application library." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.0.1" [package.extras] dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] watchdog = ["watchdog"] [[package]] -category = "dev" -description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version < \"3.8\"" name = "zipp" +version = "3.4.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" optional = false python-versions = ">=3.6" -version = "3.1.0" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["jaraco.itertools", "func-timeout"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] -content-hash = "2d9ef41b299ed10e058dfec57631714e9c932e2e3826194e341d92edd2371abb" -lock-version = "1.0" +lock-version = "1.1" python-versions = "^3.7" +content-hash = "e4014ee68179696ae11e98413b379d11d25c4169a7373a36218c2b085c95cc8f" [metadata.files] +alembic = [ + {file = "alembic-1.4.3-py2.py3-none-any.whl", hash = "sha256:4e02ed2aa796bd179965041afa092c55b51fb077de19d61835673cc80672c01c"}, + {file = "alembic-1.4.3.tar.gz", hash = "sha256:5334f32314fb2a56d86b4c4dd1ae34b08c03cae4cb888bc699942104d66bc245"}, +] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] appnope = [ - {file = "appnope-0.1.0-py2.py3-none-any.whl", hash = "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0"}, - {file = "appnope-0.1.0.tar.gz", hash = "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"}, + {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"}, + {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, - {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, ] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, ] +banal = [ + {file = "banal-1.0.1-py2.py3-none-any.whl", hash = "sha256:6431876cda7b8ff27b1a64fef3a4bdc1f3f54f8dff7beaa93241e01336a0e91d"}, + {file = "banal-1.0.1.tar.gz", hash = "sha256:5541e7c98ea04841f4ff2887bbc3f2dccf982549a99d01c0939aac250fffcf7a"}, +] black = [ {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] certifi = [ - {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, - {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, + {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, + {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, +] +cfgv = [ + {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, + {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, ] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, @@ -590,42 +859,98 @@ click = [ {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] colorama = [ - {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, - {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +coverage = [ + {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, + {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, + {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, + {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, + {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, + {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, + {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, + {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, + {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, + {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, + {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, + {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, + {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, + {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, + {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, + {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, + {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, + {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, + {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, + {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, +] +dataset = [ + {file = "dataset-1.4.1-py2.py3-none-any.whl", hash = "sha256:b6b93662e4fb4240d4d68da4156b7b0713cd3d639455d9c2411636ad0ead59de"}, + {file = "dataset-1.4.1.tar.gz", hash = "sha256:97902a3d4d62a506c74904fa7c2512a982f12f9892786f2e77b2cdfbd9f72388"}, ] decorator = [ {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, ] dice = [ - {file = "dice-3.1.0-py2.py3-none-any.whl", hash = "sha256:f7ec550f8a919b60688e355f5fc6acbf878a5d0930d11af261838b64b80d6aed"}, - {file = "dice-3.1.0.tar.gz", hash = "sha256:edcf108e5372b40cfcb3795b0ff7fa0cf515cbf4bf5d720f1d412fd5a098f6aa"}, + {file = "dice-3.1.1-py2.py3-none-any.whl", hash = "sha256:43c427532d64baefda5bb5d29a4bba1adad1f4b5cc2b8ec28dfcbb7228765385"}, + {file = "dice-3.1.1.tar.gz", hash = "sha256:99d9c3a90c4f0d016911526c1ea5a10394e047cc3ce61eab22fd34c0f4bc9f60"}, +] +distlib = [ + {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, + {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, ] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +] flake8 = [ - {file = "flake8-3.8.3-py2.py3-none-any.whl", hash = "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c"}, - {file = "flake8-3.8.3.tar.gz", hash = "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"}, + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, ] flask = [ {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, ] +identify = [ + {file = "identify-1.5.13-py2.py3-none-any.whl", hash = "sha256:9dfb63a2e871b807e3ba62f029813552a24b5289504f5b071dea9b041aee9fe4"}, + {file = "identify-1.5.13.tar.gz", hash = "sha256:70b638cf4743f33042bebb3b51e25261a0a10e80f978739f17e7fd4837664a66"}, +] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] importlib-metadata = [ - {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, - {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, + {file = "importlib_metadata-3.1.1-py3-none-any.whl", hash = "sha256:6112e21359ef8f344e7178aa5b72dc6e62b38b0d008e6d3cb212c5b84df72013"}, + {file = "importlib_metadata-3.1.1.tar.gz", hash = "sha256:b0c2d3b226157ae4517d9625decf63591461c66b3a808c2666d538946519d170"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] ipdb = [ - {file = "ipdb-0.13.3.tar.gz", hash = "sha256:d6f46d261c45a65e65a2f7ec69288a1c511e16206edb2875e7ec6b2f66997e78"}, + {file = "ipdb-0.13.4.tar.gz", hash = "sha256:c85398b5fb82f82399fc38c44fe3532c0dde1754abee727d8f5cfcc74547b334"}, ] ipython = [ - {file = "ipython-7.18.1-py3-none-any.whl", hash = "sha256:2e22c1f74477b5106a6fb301c342ab8c64bb75d702e350f05a649e8cb40a0fb8"}, - {file = "ipython-7.18.1.tar.gz", hash = "sha256:a331e78086001931de9424940699691ad49dfb457cea31f5471eae7b78222d5e"}, + {file = "ipython-7.19.0-py3-none-any.whl", hash = "sha256:c987e8178ced651532b3b1ff9965925bfd445c279239697052561a9ab806d28f"}, + {file = "ipython-7.19.0.tar.gz", hash = "sha256:cbb2ef3d5961d44e6a963b9817d4ea4e1fa2eb589c371a470fed14d8d40cbd6a"}, ] ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, @@ -647,6 +972,10 @@ jinja2 = [ {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] +mako = [ + {file = "Mako-1.1.3-py2.py3-none-any.whl", hash = "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9"}, + {file = "Mako-1.1.3.tar.gz", hash = "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27"}, +] markupsafe = [ {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, @@ -666,33 +995,60 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b"}, {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win32.whl", hash = "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +nodeenv = [ + {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, + {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, +] +packaging = [ + {file = "packaging-20.7-py2.py3-none-any.whl", hash = "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"}, + {file = "packaging-20.7.tar.gz", hash = "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236"}, +] parso = [ {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"}, {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"}, ] pathspec = [ - {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, - {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, + {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, + {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, ] pexpect = [ {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, @@ -702,14 +1058,26 @@ pickleshare = [ {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +pre-commit = [ + {file = "pre_commit-2.10.0-py2.py3-none-any.whl", hash = "sha256:391ed331fdd0a21d0be48c1b9919921e9d372dfd60f6dc77b8f01dd6b13161c1"}, + {file = "pre_commit-2.10.0.tar.gz", hash = "sha256:f413348d3a8464b77987e36ef6e02c3372dadb823edf0dfe6fb0c3dc2f378ef9"}, +] prompt-toolkit = [ - {file = "prompt_toolkit-3.0.7-py3-none-any.whl", hash = "sha256:83074ee28ad4ba6af190593d4d4c607ff525272a504eb159199b6dd9f950c950"}, - {file = "prompt_toolkit-3.0.7.tar.gz", hash = "sha256:822f4605f28f7d2ba6b0b09a31e25e140871e96364d1d377667b547bb3bf4489"}, + {file = "prompt_toolkit-3.0.8-py3-none-any.whl", hash = "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63"}, + {file = "prompt_toolkit-3.0.8.tar.gz", hash = "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c"}, ] ptyprocess = [ {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"}, {file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"}, ] +py = [ + {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, + {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, +] pycodestyle = [ {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, @@ -719,39 +1087,101 @@ pyflakes = [ {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] pygments = [ - {file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"}, - {file = "Pygments-2.7.1.tar.gz", hash = "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"}, + {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"}, + {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] +pytest = [ + {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, + {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, +] +pytest-cov = [ + {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, + {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] +python-editor = [ + {file = "python-editor-1.0.4.tar.gz", hash = "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b"}, + {file = "python_editor-1.0.4-py2-none-any.whl", hash = "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8"}, + {file = "python_editor-1.0.4-py2.7.egg", hash = "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522"}, + {file = "python_editor-1.0.4-py3-none-any.whl", hash = "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d"}, + {file = "python_editor-1.0.4-py3.5.egg", hash = "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77"}, +] +pyyaml = [ + {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, + {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, + {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, + {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, + {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, + {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, + {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, + {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, + {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, +] regex = [ - {file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"}, - {file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"}, - {file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"}, - {file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"}, - {file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"}, - {file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a"}, - {file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"}, - {file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"}, - {file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"}, + {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, + {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, + {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, + {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, + {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, + {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, + {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, + {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, + {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, + {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, + {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, + {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, + {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, ] requests = [ - {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, - {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, + {file = "requests-2.25.0-py2.py3-none-any.whl", hash = "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"}, + {file = "requests-2.25.0.tar.gz", hash = "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8"}, ] rope = [ {file = "rope-0.16.0-py2-none-any.whl", hash = "sha256:ae1fa2fd56f64f4cc9be46493ce54bed0dd12dee03980c61a4393d89d84029ad"}, @@ -762,17 +1192,57 @@ six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] +sqlalchemy = [ + {file = "SQLAlchemy-1.3.20-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bad73f9888d30f9e1d57ac8829f8a12091bdee4949b91db279569774a866a18e"}, + {file = "SQLAlchemy-1.3.20-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:e32e3455db14602b6117f0f422f46bc297a3853ae2c322ecd1e2c4c04daf6ed5"}, + {file = "SQLAlchemy-1.3.20-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:5cdfe54c1e37279dc70d92815464b77cd8ee30725adc9350f06074f91dbfeed2"}, + {file = "SQLAlchemy-1.3.20-cp27-cp27m-win32.whl", hash = "sha256:2e9bd5b23bba8ae8ce4219c9333974ff5e103c857d9ff0e4b73dc4cb244c7d86"}, + {file = "SQLAlchemy-1.3.20-cp27-cp27m-win_amd64.whl", hash = "sha256:5d92c18458a4aa27497a986038d5d797b5279268a2de303cd00910658e8d149c"}, + {file = "SQLAlchemy-1.3.20-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:53fd857c6c8ffc0aa6a5a3a2619f6a74247e42ec9e46b836a8ffa4abe7aab327"}, + {file = "SQLAlchemy-1.3.20-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:0a92745bb1ebbcb3985ed7bda379b94627f0edbc6c82e9e4bac4fb5647ae609a"}, + {file = "SQLAlchemy-1.3.20-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:b6f036ecc017ec2e2cc2a40615b41850dc7aaaea6a932628c0afc73ab98ba3fb"}, + {file = "SQLAlchemy-1.3.20-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3aa6d45e149a16aa1f0c46816397e12313d5e37f22205c26e06975e150ffcf2a"}, + {file = "SQLAlchemy-1.3.20-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:ed53209b5f0f383acb49a927179fa51a6e2259878e164273ebc6815f3a752465"}, + {file = "SQLAlchemy-1.3.20-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:d3b709d64b5cf064972b3763b47139e4a0dc4ae28a36437757f7663f67b99710"}, + {file = "SQLAlchemy-1.3.20-cp35-cp35m-win32.whl", hash = "sha256:950f0e17ffba7a7ceb0dd056567bc5ade22a11a75920b0e8298865dc28c0eff6"}, + {file = "SQLAlchemy-1.3.20-cp35-cp35m-win_amd64.whl", hash = "sha256:8dcbf377529a9af167cbfc5b8acec0fadd7c2357fc282a1494c222d3abfc9629"}, + {file = "SQLAlchemy-1.3.20-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0157c269701d88f5faf1fa0e4560e4d814f210c01a5b55df3cab95e9346a8bcc"}, + {file = "SQLAlchemy-1.3.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:7cd40cb4bc50d9e87b3540b23df6e6b24821ba7e1f305c1492b0806c33dbdbec"}, + {file = "SQLAlchemy-1.3.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:c092fe282de83d48e64d306b4bce03114859cdbfe19bf8a978a78a0d44ddadb1"}, + {file = "SQLAlchemy-1.3.20-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:166917a729b9226decff29416f212c516227c2eb8a9c9f920d69ced24e30109f"}, + {file = "SQLAlchemy-1.3.20-cp36-cp36m-win32.whl", hash = "sha256:632b32183c0cb0053194a4085c304bc2320e5299f77e3024556fa2aa395c2a8b"}, + {file = "SQLAlchemy-1.3.20-cp36-cp36m-win_amd64.whl", hash = "sha256:bbc58fca72ce45a64bb02b87f73df58e29848b693869e58bd890b2ddbb42d83b"}, + {file = "SQLAlchemy-1.3.20-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b15002b9788ffe84e42baffc334739d3b68008a973d65fad0a410ca5d0531980"}, + {file = "SQLAlchemy-1.3.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:9e379674728f43a0cd95c423ac0e95262500f9bfd81d33b999daa8ea1756d162"}, + {file = "SQLAlchemy-1.3.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:2b5dafed97f778e9901b79cc01b88d39c605e0545b4541f2551a2fd785adc15b"}, + {file = "SQLAlchemy-1.3.20-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:fcdb3755a7c355bc29df1b5e6fb8226d5c8b90551d202d69d0076a8a5649d68b"}, + {file = "SQLAlchemy-1.3.20-cp37-cp37m-win32.whl", hash = "sha256:bca4d367a725694dae3dfdc86cf1d1622b9f414e70bd19651f5ac4fb3aa96d61"}, + {file = "SQLAlchemy-1.3.20-cp37-cp37m-win_amd64.whl", hash = "sha256:f605f348f4e6a2ba00acb3399c71d213b92f27f2383fc4abebf7a37368c12142"}, + {file = "SQLAlchemy-1.3.20-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:84f0ac4a09971536b38cc5d515d6add7926a7e13baa25135a1dbb6afa351a376"}, + {file = "SQLAlchemy-1.3.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2909dffe5c9a615b7e6c92d1ac2d31e3026dc436440a4f750f4749d114d88ceb"}, + {file = "SQLAlchemy-1.3.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c3ab23ee9674336654bf9cac30eb75ac6acb9150dc4b1391bec533a7a4126471"}, + {file = "SQLAlchemy-1.3.20-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:009e8388d4d551a2107632921320886650b46332f61dc935e70c8bcf37d8e0d6"}, + {file = "SQLAlchemy-1.3.20-cp38-cp38-win32.whl", hash = "sha256:bf53d8dddfc3e53a5bda65f7f4aa40fae306843641e3e8e701c18a5609471edf"}, + {file = "SQLAlchemy-1.3.20-cp38-cp38-win_amd64.whl", hash = "sha256:7c735c7a6db8ee9554a3935e741cf288f7dcbe8706320251eb38c412e6a4281d"}, + {file = "SQLAlchemy-1.3.20-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:4bdbdb8ca577c6c366d15791747c1de6ab14529115a2eb52774240c412a7b403"}, + {file = "SQLAlchemy-1.3.20-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:ce64a44c867d128ab8e675f587aae7f61bd2db836a3c4ba522d884cd7c298a77"}, + {file = "SQLAlchemy-1.3.20-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be41d5de7a8e241864189b7530ca4aaf56a5204332caa70555c2d96379e18079"}, + {file = "SQLAlchemy-1.3.20-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1f5f369202912be72fdf9a8f25067a5ece31a2b38507bb869306f173336348da"}, + {file = "SQLAlchemy-1.3.20-cp39-cp39-win32.whl", hash = "sha256:0cca1844ba870e81c03633a99aa3dc62256fb96323431a5dec7d4e503c26372d"}, + {file = "SQLAlchemy-1.3.20-cp39-cp39-win_amd64.whl", hash = "sha256:d05cef4a164b44ffda58200efcb22355350979e000828479971ebca49b82ddb1"}, + {file = "SQLAlchemy-1.3.20.tar.gz", hash = "sha256:d2f25c7f410338d31666d7ddedfa67570900e248b940d186b48461bd4e5569a1"}, +] structlog = [ {file = "structlog-20.1.0-py2.py3-none-any.whl", hash = "sha256:8a672be150547a93d90a7d74229a29e765be05bd156a35cdcc527ebf68e9af92"}, {file = "structlog-20.1.0.tar.gz", hash = "sha256:7a48375db6274ed1d0ae6123c486472aa1d0890b08d314d2b016f3aa7f35990b"}, ] toml = [ - {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, - {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] traitlets = [ - {file = "traitlets-5.0.4-py3-none-any.whl", hash = "sha256:9664ec0c526e48e7b47b7d14cd6b252efa03e0129011de0a9c1d70315d4309c3"}, - {file = "traitlets-5.0.4.tar.gz", hash = "sha256:86c9351f94f95de9db8a04ad8e892da299a088a64fd283f9f6f18770ae5eae1b"}, + {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"}, + {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"}, ] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, @@ -807,8 +1277,12 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] urllib3 = [ - {file = "urllib3-1.25.11-py2.py3-none-any.whl", hash = "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"}, - {file = "urllib3-1.25.11.tar.gz", hash = "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2"}, + {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, + {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, +] +virtualenv = [ + {file = "virtualenv-20.4.2-py2.py3-none-any.whl", hash = "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"}, + {file = "virtualenv-20.4.2.tar.gz", hash = "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d"}, ] waitress = [ {file = "waitress-1.4.4-py2.py3-none-any.whl", hash = "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"}, @@ -823,6 +1297,6 @@ werkzeug = [ {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"}, ] zipp = [ - {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, - {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, + {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, + {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, ] diff --git a/pyproject.toml b/pyproject.toml index ca320ab..1e485cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "butterrobot" -version = "0.0.2a4" +version = "0.0.3" description = "What is my purpose?" authors = ["Felipe Martin "] license = "GPL-2.0" @@ -19,6 +19,7 @@ dice = "^3.1.0" flask = "^1.1.2" requests = "^2.24.0" waitress = "^1.4.4" +dataset = "^1.3.2" [tool.poetry.dev-dependencies] black = "^19.10b0" @@ -26,6 +27,9 @@ flake8 = "^3.7.9" rope = "^0.16.0" isort = "^4.3.21" ipdb = "^0.13.2" +pytest = "^6.1.2" +pytest-cov = "^2.10.1" +pre-commit = "^2.10.0" [tool.poetry.plugins] [tool.poetry.plugins."butterrobot.plugins"] diff --git a/setup.cfg b/setup.cfg index 007f4ac..08f065e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,6 +11,6 @@ include_trailing_comma = True length_sort = 1 lines_between_types = 0 line_length = 88 -known_third_party = click,django,docker,factory,pydantic,pytest,requests,toml +known_third_party = dataset,dice,flask,pkg_resources,pytest,requests,structlog sections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LOCALFOLDER no_lines_before = LOCALFOLDER diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..38c9e2e --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,109 @@ +import os.path +import tempfile +from dataclasses import dataclass +from unittest import mock + +import dataset +import pytest + +from butterrobot import db + + +@dataclass +class DummyItem: + id: int + foo: str + + +class DummyQuery(db.Query): + tablename = "dummy" + obj = DummyItem + + +class MockDatabase: + def __init__(self): + self.temp_dir = tempfile.TemporaryDirectory() + + def __enter__(self): + db_path = os.path.join(self.temp_dir.name, "db.sqlite") + db.db = dataset.connect(f"sqlite:///{db_path}") + + def __exit__(self, exc_type, exc_val, exc_tb): + self.temp_dir.cleanup() + + +def test_query_create_ok(): + with MockDatabase(): + assert DummyQuery.create(foo="bar") + + +def test_query_delete_ok(): + with MockDatabase(): + item_id = DummyQuery.create(foo="bar") + assert DummyQuery.delete(item_id) + + +def test_query_exists_by_id_ok(): + with MockDatabase(): + assert not DummyQuery.exists(id=1) + item_id = DummyQuery.create(foo="bar") + assert DummyQuery.exists(id=item_id) + + +def test_query_exists_by_attribute_ok(): + with MockDatabase(): + assert not DummyQuery.exists(id=1) + item_id = DummyQuery.create(foo="bar") + assert DummyQuery.exists(foo="bar") + + +def test_query_get_ok(): + with MockDatabase(): + item_id = DummyQuery.create(foo="bar") + item = DummyQuery.get(id=item_id) + assert item.id + + +def test_query_all_ok(): + with MockDatabase(): + assert len(list(DummyQuery.all())) == 0 + [DummyQuery.create(foo="bar") for i in range(0, 3)] + assert len(list(DummyQuery.all())) == 3 + + +def test_update_ok(): + with MockDatabase(): + expected = "bar2" + item_id = DummyQuery.create(foo="bar") + assert DummyQuery.update(item_id, foo=expected) + item = DummyQuery.get(id=item_id) + assert item.foo == expected + + +def test_create_user_sets_password_ok(): + password = "password" + with MockDatabase(): + user_id = db.UserQuery.create(username="foo", password=password) + user = db.UserQuery.get(id=user_id) + assert user.password == db.UserQuery._hash_password(password) + + +def test_user_check_credentials_ok(): + with MockDatabase(): + username = "foo" + password = "bar" + user_id = db.UserQuery.create(username=username, password=password) + user = db.UserQuery.get(id=user_id) + user = db.UserQuery.check_credentials(username, password) + assert isinstance(user, db.UserQuery.obj) + + +def test_user_check_credentials_ko(): + with MockDatabase(): + username = "foo" + password = "bar" + user_id = db.UserQuery.create(username=username, password=password) + user = db.UserQuery.get(id=user_id) + assert not db.UserQuery.check_credentials(username, "error") + assert not db.UserQuery.check_credentials("error", password) + assert not db.UserQuery.check_credentials("error", "error") diff --git a/tests/test_objects.py b/tests/test_objects.py new file mode 100644 index 0000000..ffab485 --- /dev/null +++ b/tests/test_objects.py @@ -0,0 +1,18 @@ +from butterrobot.objects import Channel, ChannelPlugin + + +def test_channel_has_enabled_plugin_ok(): + channel = Channel( + platform="debug", + platform_channel_id="debug", + channel_raw={}, + plugins={ + "enabled": ChannelPlugin( + id=1, channel_id="test", plugin_id="enabled", enabled=True + ), + "existant": ChannelPlugin(id=2, channel_id="test", plugin_id="existant"), + }, + ) + assert not channel.has_enabled_plugin("non.existant") + assert not channel.has_enabled_plugin("existant") + assert channel.has_enabled_plugin("enabled")