From 702347708f81966c47c0a116a90846549d8e0eff Mon Sep 17 00:00:00 2001 From: Felipe Martin Garcia Date: Tue, 21 Jul 2020 17:20:55 +0200 Subject: [PATCH 01/44] 0.0.2a3 (#3) --- docker/Dockerfile | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c5408ac..398c43c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ FROM alpine:3.11 ENV PYTHON_VERSION=3.8.2-r1 ENV APP_PORT 8080 -ENV BUTTERROBOT_VERSION 0.0.2a2 +ENV BUTTERROBOT_VERSION 0.0.2a3 ENV EXTRA_DEPENDENCIES "" COPY bin/start-server.sh /usr/local/bin/start-server diff --git a/pyproject.toml b/pyproject.toml index a623e1b..4db62fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "butterrobot" -version = "0.0.2a2" +version = "0.0.2a3" description = "What is my purpose?" authors = ["Felipe Martin "] license = "GPL-2.0" From 1a7774084583f861d13ec08470a13356b7b9c7d7 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Fri, 7 Aug 2020 14:50:34 +0200 Subject: [PATCH 02/44] Typo: platformInitError --- butterrobot/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index 677d978..891c456 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -27,7 +27,7 @@ async def init_platforms(): await platform.init(app=app) available_platforms[platform.ID] = platform logger.info("platform setup completed", platform=platform.ID) - except platform.platformInitError as error: + except platform.PlatformInitError as error: logger.error(f"platform init error", error=error, platform=platform.ID) From c99aeba70860d963caeb3ee892c608d7d1c9b655 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Tue, 11 Aug 2020 12:57:56 +0200 Subject: [PATCH 03/44] Bugfixes --- butterrobot/app.py | 7 ++++++- butterrobot/lib/telegram.py | 2 +- butterrobot/plugins.py | 3 ++- docker/Dockerfile | 2 +- pyproject.toml | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index 891c456..e051f66 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -7,6 +7,7 @@ import structlog import butterrobot.logging from butterrobot.config import SLACK_TOKEN, LOG_LEVEL, 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 @@ -50,7 +51,11 @@ async def incoming_platform_message_view(platform, path=None): for plugin in enabled_plugins: if result := await plugin.on_message(message): - await available_platforms[platform].methods.send_message(result) + if isinstance(result, Message): + result = [result] + + for message in result: + await available_platforms[platform].methods.send_message(result) return {} diff --git a/butterrobot/lib/telegram.py b/butterrobot/lib/telegram.py index cd082b9..ac2bd21 100644 --- a/butterrobot/lib/telegram.py +++ b/butterrobot/lib/telegram.py @@ -31,7 +31,7 @@ class TelegramAPI: async with session.post(url, json=payload) as response: response = await response.json() if not response["ok"]: - raise cls.TelegramClientError + raise cls.TelegramClientError(response) @classmethod async def send_message( diff --git a/butterrobot/plugins.py b/butterrobot/plugins.py index b9dc5a0..b2752e8 100644 --- a/butterrobot/plugins.py +++ b/butterrobot/plugins.py @@ -4,13 +4,14 @@ from abc import abstractclassmethod import structlog +from butterrobot.objects import Message logger = structlog.get_logger(__name__) class Plugin: @abstractclassmethod - def on_message(cls, message): + async def on_message(cls, message: Message): pass diff --git a/docker/Dockerfile b/docker/Dockerfile index 398c43c..54c2ede 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ FROM alpine:3.11 ENV PYTHON_VERSION=3.8.2-r1 ENV APP_PORT 8080 -ENV BUTTERROBOT_VERSION 0.0.2a3 +ENV BUTTERROBOT_VERSION 0.0.2a4 ENV EXTRA_DEPENDENCIES "" COPY bin/start-server.sh /usr/local/bin/start-server diff --git a/pyproject.toml b/pyproject.toml index 4db62fb..0bcd2c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "butterrobot" -version = "0.0.2a3" +version = "0.0.2a4" description = "What is my purpose?" authors = ["Felipe Martin "] license = "GPL-2.0" From 976c6a3ea335cb130ea7a9afb5d2b554d2f5f9dd Mon Sep 17 00:00:00 2001 From: Felipe M Date: Tue, 11 Aug 2020 13:06:51 +0200 Subject: [PATCH 04/44] Typo --- butterrobot/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index e051f66..fa19281 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -55,7 +55,7 @@ async def incoming_platform_message_view(platform, path=None): result = [result] for message in result: - await available_platforms[platform].methods.send_message(result) + await available_platforms[platform].methods.send_message(message) return {} From e1c158bd6cdad83b121d22826ce3863e2c99eaf8 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Tue, 11 Aug 2020 13:16:34 +0200 Subject: [PATCH 05/44] message -> out_message --- butterrobot/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index fa19281..2f04ea9 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -54,8 +54,8 @@ async def incoming_platform_message_view(platform, path=None): if isinstance(result, Message): result = [result] - for message in result: - await available_platforms[platform].methods.send_message(message) + for out_message in result: + await available_platforms[platform].methods.send_message(out_message) return {} From 562d7138c05401c3146b8265d9c07620c91fba6b Mon Sep 17 00:00:00 2001 From: Felipe M Date: Tue, 11 Aug 2020 13:29:24 +0200 Subject: [PATCH 06/44] Added author information to message --- butterrobot/app.py | 17 +++++++++++++---- butterrobot/objects.py | 2 ++ butterrobot/platforms/slack.py | 2 ++ butterrobot/platforms/telegram.py | 2 ++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index 2f04ea9..8487949 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -17,7 +17,9 @@ logger = structlog.get_logger(__name__) app = Quart(__name__) available_platforms = {} plugins = get_available_plugins() -enabled_plugins = [plugin for plugin_name, plugin in plugins.items() if plugin_name in ENABLED_PLUGINS] +enabled_plugins = [ + plugin for plugin_name, plugin in plugins.items() if plugin_name in ENABLED_PLUGINS +] @app.before_serving @@ -39,11 +41,18 @@ async def incoming_platform_message_view(platform, path=None): return {"error": "Unknown platform"}, 400 try: - message = await available_platforms[platform].parse_incoming_message(request=request) + message = await 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(f"Error parsing message", platform=platform, error=error, traceback=traceback.format_exc()) + logger.error( + f"Error parsing message", + platform=platform, + error=error, + traceback=traceback.format_exc(), + ) return {"error": str(error)}, 400 if not message: @@ -53,7 +62,7 @@ async def incoming_platform_message_view(platform, path=None): if result := await plugin.on_message(message): if isinstance(result, Message): result = [result] - + for out_message in result: await available_platforms[platform].methods.send_message(out_message) diff --git a/butterrobot/objects.py b/butterrobot/objects.py index 0c734ee..8ef22ca 100644 --- a/butterrobot/objects.py +++ b/butterrobot/objects.py @@ -7,6 +7,8 @@ from typing import Text, Optional class Message: text: Text chat: Text + author: Text + is_bot: bool = False date: Optional[datetime] = None id: Optional[Text] = None reply_to: Optional[Text] = None diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py index 71d9d7c..f6d1865 100644 --- a/butterrobot/platforms/slack.py +++ b/butterrobot/platforms/slack.py @@ -63,6 +63,8 @@ class SlackPlatform(Platform): return Message( id=data["event"].get("thread_ts", data["event"]["ts"]), + author=data["event"]["user"], + is_bot="bot_id" in data["event"], date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))), text=data["event"]["text"], chat=data["event"]["channel"], diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py index 7506861..602fd33 100644 --- a/butterrobot/platforms/telegram.py +++ b/butterrobot/platforms/telegram.py @@ -61,6 +61,8 @@ class TelegramPlatform(Platform): id=request_data["message"]["message_id"], date=datetime.fromtimestamp(request_data["message"]["date"]), text=str(request_data["message"]["text"]), + is_bot=request_data["message"]["from"]["is_bot"], + author=request_data["message"]["from"]["id"], chat=str(request_data["message"]["chat"]["id"]), raw=request_data, ) From 1ae02d29734725f40c469f2a71ef29590e7fd605 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Tue, 11 Aug 2020 13:37:17 +0200 Subject: [PATCH 07/44] Ignore messages from bots --- butterrobot/app.py | 2 +- butterrobot/objects.py | 2 +- butterrobot/platforms/slack.py | 2 +- butterrobot/platforms/telegram.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index 8487949..6657114 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -55,7 +55,7 @@ async def incoming_platform_message_view(platform, path=None): ) return {"error": str(error)}, 400 - if not message: + if not message or message.from_bot: return {} for plugin in enabled_plugins: diff --git a/butterrobot/objects.py b/butterrobot/objects.py index 8ef22ca..926e6a7 100644 --- a/butterrobot/objects.py +++ b/butterrobot/objects.py @@ -8,7 +8,7 @@ class Message: text: Text chat: Text author: Text - is_bot: bool = False + from_bot: bool = False date: Optional[datetime] = None id: Optional[Text] = None reply_to: Optional[Text] = None diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py index f6d1865..d40a57f 100644 --- a/butterrobot/platforms/slack.py +++ b/butterrobot/platforms/slack.py @@ -64,7 +64,7 @@ class SlackPlatform(Platform): return Message( id=data["event"].get("thread_ts", data["event"]["ts"]), author=data["event"]["user"], - is_bot="bot_id" in data["event"], + from_bot="bot_id" in data["event"], date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))), text=data["event"]["text"], chat=data["event"]["channel"], diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py index 602fd33..a83c9b6 100644 --- a/butterrobot/platforms/telegram.py +++ b/butterrobot/platforms/telegram.py @@ -61,7 +61,7 @@ class TelegramPlatform(Platform): id=request_data["message"]["message_id"], date=datetime.fromtimestamp(request_data["message"]["date"]), text=str(request_data["message"]["text"]), - is_bot=request_data["message"]["from"]["is_bot"], + from_bot=request_data["message"]["from"]["from_bot"], author=request_data["message"]["from"]["id"], chat=str(request_data["message"]["chat"]["id"]), raw=request_data, From cd8e552191700c5cbdef4e00d0eb3c2370981d80 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Tue, 11 Aug 2020 13:52:04 +0200 Subject: [PATCH 08/44] Allow empty author when sending messages --- butterrobot/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/butterrobot/objects.py b/butterrobot/objects.py index 926e6a7..55051c7 100644 --- a/butterrobot/objects.py +++ b/butterrobot/objects.py @@ -7,7 +7,7 @@ from typing import Text, Optional class Message: text: Text chat: Text - author: Text + author: Text = None from_bot: bool = False date: Optional[datetime] = None id: Optional[Text] = None From e1f525664112c2215e0f84f77d3cccd20888e5b2 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Mon, 17 Aug 2020 12:12:29 +0200 Subject: [PATCH 09/44] Added .drone.yml file --- .drone.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .drone.yml diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..888d9c1 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,11 @@ +kind: pipeline +name: Build latest docker image locally + +steps: +- name: Build and publish + image: plugins/docker + settings: + repo: registry.int.fmartingr.network/fmartingr/butterrobot + tags: latest + registry: registry.int.fmartingr.network + dockerfile: docker/Dockerfile From 859d5e7c624a34d170503f84e0d77baa49429b3f Mon Sep 17 00:00:00 2001 From: Felipe M Date: Mon, 17 Aug 2020 12:29:25 +0200 Subject: [PATCH 10/44] Add context path to drone.yml --- .drone.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.drone.yml b/.drone.yml index 888d9c1..10eeee4 100644 --- a/.drone.yml +++ b/.drone.yml @@ -9,3 +9,4 @@ steps: tags: latest registry: registry.int.fmartingr.network dockerfile: docker/Dockerfile + context: docker \ No newline at end of file From 2546eb6f374b6724958e0f1d0406f1a4d1ae9e95 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Mon, 17 Aug 2020 12:48:45 +0200 Subject: [PATCH 11/44] .drone.yml build dev instead of stable --- .drone.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.yml b/.drone.yml index 10eeee4..d63f8a5 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,5 +8,5 @@ steps: repo: registry.int.fmartingr.network/fmartingr/butterrobot tags: latest registry: registry.int.fmartingr.network - dockerfile: docker/Dockerfile - context: docker \ No newline at end of file + dockerfile: Dockerfile.dev + \ No newline at end of file From 08437e7a1cd10033144953be05c8e8bc3d4ae816 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Tue, 18 Aug 2020 12:57:07 +0200 Subject: [PATCH 12/44] Delete .drone.yml file --- .drone.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .drone.yml diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index d63f8a5..0000000 --- a/.drone.yml +++ /dev/null @@ -1,12 +0,0 @@ -kind: pipeline -name: Build latest docker image locally - -steps: -- name: Build and publish - image: plugins/docker - settings: - repo: registry.int.fmartingr.network/fmartingr/butterrobot - tags: latest - registry: registry.int.fmartingr.network - dockerfile: Dockerfile.dev - \ No newline at end of file From bd3d948e8c8bfdf5bc5ced6c61f55488de270b4d Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 16:08:42 +0200 Subject: [PATCH 13/44] Using asyncio to handle messages as futures --- butterrobot/app.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index 6657114..95b95a7 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -1,12 +1,11 @@ import asyncio import traceback -import urllib.parse from quart import Quart, request import structlog -import butterrobot.logging -from butterrobot.config import SLACK_TOKEN, LOG_LEVEL, ENABLED_PLUGINS +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 @@ -22,6 +21,12 @@ enabled_plugins = [ ] +async def handle_message(platform: str, message: Message): + for plugin in enabled_plugins: + async for response_message in plugin.on_message(message): + asyncio.ensure_future(available_platforms[platform].methods.send_message(response_message)) + + @app.before_serving async def init_platforms(): for platform in PLATFORMS.values(): @@ -31,7 +36,7 @@ async def init_platforms(): available_platforms[platform.ID] = platform logger.info("platform setup completed", platform=platform.ID) except platform.PlatformInitError as error: - logger.error(f"platform init error", error=error, platform=platform.ID) + logger.error("Platform init error", error=error, platform=platform.ID) @app.route("//incoming", methods=["POST"]) @@ -48,7 +53,7 @@ async def incoming_platform_message_view(platform, path=None): return response.data, response.status_code except Exception as error: logger.error( - f"Error parsing message", + "Error parsing message", platform=platform, error=error, traceback=traceback.format_exc(), @@ -58,13 +63,7 @@ async def incoming_platform_message_view(platform, path=None): if not message or message.from_bot: return {} - for plugin in enabled_plugins: - if result := await plugin.on_message(message): - if isinstance(result, Message): - result = [result] - - for out_message in result: - await available_platforms[platform].methods.send_message(out_message) + asyncio.ensure_future(handle_message(platform, message)) return {} From 31df4334202a583d30c96539489daae645b5b563 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 16:09:02 +0200 Subject: [PATCH 14/44] Added a debug platform for debugging --- butterrobot/platforms/__init__.py | 3 ++- butterrobot/platforms/debug.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 butterrobot/platforms/debug.py diff --git a/butterrobot/platforms/__init__.py b/butterrobot/platforms/__init__.py index ff08cef..c63b65e 100644 --- a/butterrobot/platforms/__init__.py +++ b/butterrobot/platforms/__init__.py @@ -1,5 +1,6 @@ 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,)} +PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform, DebugPlatform)} diff --git a/butterrobot/platforms/debug.py b/butterrobot/platforms/debug.py new file mode 100644 index 0000000..3d59230 --- /dev/null +++ b/butterrobot/platforms/debug.py @@ -0,0 +1,39 @@ +import uuid +from datetime import datetime + +import structlog + +from butterrobot.platforms.base import Platform, PlatformMethods +from butterrobot.objects import Message + + +logger = structlog.get_logger(__name__) + + +class DebugMethods(PlatformMethods): + @classmethod + async def send_message(self, message: Message): + logger.debug( + "Outgoing message", message=message.__dict__, platform=DebugPlatform.ID + ) + + +class DebugPlatform(Platform): + ID = "debug" + + methods = DebugMethods + + @classmethod + async def parse_incoming_message(cls, request): + request_data = await request.get_json() + logger.debug("Parsing message", data=request_data, platform=cls.ID) + + return Message( + id=str(uuid.uuid4()), + date=datetime.now(), + text=request_data["text"], + from_bot=bool(request_data.get("from_bot", False)), + author=request_data.get("author", "Debug author"), + chat=request_data.get("chat", "Debug chat ID"), + raw={}, + ) From 2e7326cef7dd9e989cbf097c50e894f1a0c95f0d Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 16:09:21 +0200 Subject: [PATCH 15/44] Slack platform now properly ignores bot messages --- butterrobot/platforms/slack.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py index d40a57f..3bb2e21 100644 --- a/butterrobot/platforms/slack.py +++ b/butterrobot/platforms/slack.py @@ -44,7 +44,6 @@ class SlackPlatform(Platform): @classmethod async def parse_incoming_message(cls, request): data = await request.get_json() - logger.debug("Parsing message", platform=cls.ID, data=data) # Auth if data.get("token") != SLACK_TOKEN: @@ -54,13 +53,15 @@ class SlackPlatform(Platform): if "challenge" in data: raise cls.PlatformAuthResponse(data={"challenge": data["challenge"]}) - # Discard messages by bots + # Discard messages by webhooks and apps if "bot_id" in data["event"]: + logger.debug("Discarding message", data=data) return if data["event"]["type"] != "message": return + logger.debug("Parsing message", platform=cls.ID, data=data) return Message( id=data["event"].get("thread_ts", data["event"]["ts"]), author=data["event"]["user"], From 8bf77f91f11070de2a5fedcdc327a97158e0189b Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 16:09:31 +0200 Subject: [PATCH 16/44] return -> yield --- butterrobot_plugins_contrib/dev.py | 2 +- butterrobot_plugins_contrib/fun.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/butterrobot_plugins_contrib/dev.py b/butterrobot_plugins_contrib/dev.py index 18b9a8c..fec9833 100644 --- a/butterrobot_plugins_contrib/dev.py +++ b/butterrobot_plugins_contrib/dev.py @@ -12,6 +12,6 @@ class PingPlugin(Plugin): if message.text == "!ping": delta = datetime.now() - message.date delta_ms = delta.seconds * 1000 + delta.microseconds / 1000 - return Message( + yield Message( chat=message.chat, reply_to=message.id, text=f"pong! ({delta_ms}ms)", ) diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py index 78b4755..70f9c6c 100644 --- a/butterrobot_plugins_contrib/fun.py +++ b/butterrobot_plugins_contrib/fun.py @@ -8,4 +8,4 @@ class LoquitoPlugin(Plugin): @classmethod async def on_message(cls, message): if "lo quito" in message.text.lower(): - return Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",) + yield Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",) From 5df23c2f5a954b4cbae2ebb4eafbfabb0091e959 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 16:09:38 +0200 Subject: [PATCH 17/44] Updated dependencies --- poetry.lock | 608 ++++++++++++++++++++++++++-------------------------- 1 file changed, 306 insertions(+), 302 deletions(-) diff --git a/poetry.lock b/poetry.lock index 85c3ddf..02c7c2d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,18 +1,21 @@ [[package]] -category = "main" -description = "File support for asyncio." name = "aiofiles" +version = "0.5.0" +description = "File support for asyncio." +category = "main" optional = false python-versions = "*" -version = "0.5.0" [[package]] -category = "main" -description = "Async http client/server framework (asyncio)" name = "aiohttp" +version = "3.6.2" +description = "Async http client/server framework (asyncio)" +category = "main" optional = false python-versions = ">=3.5.3" -version = "3.6.2" + +[package.extras] +speedups = ["aiodns", "brotlipy", "cchardet"] [package.dependencies] async-timeout = ">=3.0,<4.0" @@ -21,64 +24,64 @@ chardet = ">=2.0,<4.0" multidict = ">=4.5,<5.0" yarl = ">=1.0,<2.0" -[package.extras] -speedups = ["aiodns", "brotlipy", "cchardet"] - [[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]] +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 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 = "main" -description = "Timeout context manager for asyncio programs" +name = "appnope" +version = "0.1.0" +description = "Disable App Nap on OS X 10.9" +category = "dev" +optional = false +python-versions = "*" +marker = "python_version >= \"3.4\" and sys_platform == \"darwin\"" + +[[package]] name = "async-timeout" +version = "3.0.1" +description = "Timeout context manager for asyncio programs" +category = "main" optional = false python-versions = ">=3.5.3" -version = "3.0.1" [[package]] -category = "main" -description = "Classes Without Boilerplate" name = "attrs" +version = "20.2.0" +description = "Classes Without Boilerplate" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" [package.extras] -azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] -dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] -docs = ["sphinx", "zope.interface"] -tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +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]] -category = "dev" -description = "Specifications for callback functions passed in to an API" -marker = "python_version >= \"3.4\"" name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" optional = false python-versions = "*" -version = "0.2.0" +marker = "python_version >= \"3.4\"" [[package]] -category = "dev" -description = "The uncompromising code formatter." name = "black" +version = "19.10b0" +description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.6" -version = "19.10b0" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [package.dependencies] appdirs = "*" @@ -89,57 +92,54 @@ regex = "*" toml = ">=0.9.4" typed-ast = ">=1.4.0" -[package.extras] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] - [[package]] -category = "main" -description = "Fast, simple object-to-object and broadcast signaling" name = "blinker" -optional = false -python-versions = "*" version = "1.4" - -[[package]] +description = "Fast, simple object-to-object and broadcast signaling" category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" optional = false python-versions = "*" + +[[package]] +name = "chardet" version = "3.0.4" +description = "Universal encoding detector for Python 2 and 3" +category = "main" +optional = false +python-versions = "*" [[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]] +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.3" +description = "Cross-platform colored terminal text." 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]] -category = "dev" -description = "Decorators for Humans" -marker = "python_version >= \"3.4\"" 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" +marker = "python_version >= \"3.4\"" [[package]] -category = "dev" -description = "the modular source code checker: pep8 pyflakes and co" name = "flake8" +version = "3.8.3" +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] mccabe = ">=0.6.0,<0.7.0" @@ -147,44 +147,50 @@ pycodestyle = ">=2.6.0a1,<2.7.0" pyflakes = ">=2.2.0,<2.3.0" [package.dependencies.importlib-metadata] -python = "<3.8" version = "*" +python = "<3.8" [[package]] -category = "main" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" name = "h11" +version = "0.10.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" optional = false python-versions = "*" -version = "0.9.0" [[package]] -category = "main" -description = "HTTP/2 State-Machine based protocol implementation" name = "h2" +version = "3.2.0" +description = "HTTP/2 State-Machine based protocol implementation" +category = "main" optional = false python-versions = "*" -version = "3.2.0" [package.dependencies] hpack = ">=3.0,<4" hyperframe = ">=5.2.0,<6" [[package]] -category = "main" -description = "Pure-Python HPACK header compression" name = "hpack" +version = "3.0.0" +description = "Pure-Python HPACK header compression" +category = "main" optional = false python-versions = "*" -version = "3.0.0" [[package]] -category = "main" -description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn." name = "hypercorn" +version = "0.10.2" +description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn." +category = "main" optional = false python-versions = ">=3.7" -version = "0.10.1" + +[package.extras] +h3 = ["aioquic (>=0.9.0,<1.0)"] +tests = ["hypothesis", "mock", "pytest", "pytest-asyncio", "pytest-cov", "pytest-trio", "trio"] +trio = ["trio (>=0.11.0)"] +uvloop = ["uvloop"] [package.dependencies] h11 = "*" @@ -194,80 +200,61 @@ toml = "*" typing-extensions = "*" wsproto = ">=0.14.0" -[package.extras] -h3 = ["aioquic (>=0.9.0,<1.0)"] -tests = ["hypothesis", "mock", "pytest", "pytest-asyncio", "pytest-cov", "pytest-trio", "trio"] -trio = ["trio (>=0.11.0)"] -uvloop = ["uvloop"] - [[package]] -category = "main" -description = "HTTP/2 framing layer for Python" name = "hyperframe" +version = "5.2.0" +description = "HTTP/2 framing layer for Python" +category = "main" optional = false python-versions = "*" -version = "5.2.0" [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.10" [[package]] -category = "dev" -description = "Read metadata from Python packages" -marker = "python_version < \"3.8\"" name = "importlib-metadata" +version = "1.7.0" +description = "Read metadata from Python packages" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" - -[package.dependencies] -zipp = ">=0.5" +marker = "python_version < \"3.8\"" [package.extras] docs = ["sphinx", "rst.linker"] testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] +[package.dependencies] +zipp = ">=0.5" + [[package]] -category = "dev" -description = "IPython-enabled pdb" name = "ipdb" +version = "0.13.3" +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" +python = ">=3.4" [[package]] -category = "dev" -description = "IPython: Productive Interactive Computing" -marker = "python_version >= \"3.4\"" name = "ipython" +version = "7.18.1" +description = "IPython: Productive Interactive Computing" +category = "dev" optional = false -python-versions = ">=3.6" -version = "7.16.1" - -[package.dependencies] -appnope = "*" -backcall = "*" -colorama = "*" -decorator = "*" -jedi = ">=0.10" -pexpect = "*" -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" +python-versions = ">=3.7" +marker = "python_version >= \"3.4\"" [package.extras] all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] @@ -280,22 +267,35 @@ parallel = ["ipyparallel"] 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" -optional = false -python-versions = "*" -version = "0.2.0" +[package.dependencies] +appnope = "*" +backcall = "*" +colorama = "*" +decorator = "*" +jedi = ">=0.10" +pexpect = ">4.3" +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]] +name = "ipython-genutils" +version = "0.2.0" +description = "Vestigial utilities from IPython" category = "dev" -description = "A Python utility / library to sort Python imports." +optional = false +python-versions = "*" +marker = "python_version >= \"3.4\"" + +[[package]] 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"] @@ -304,169 +304,172 @@ 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" +marker = "python_version >= \"3.4\"" [package.extras] qa = ["flake8 (3.7.9)"] testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] +[package.dependencies] +parso = ">=0.7.0,<0.8.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" [package.extras] i18n = ["Babel (>=0.8)"] +[package.dependencies] +MarkupSafe = ">=0.23" + [[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." 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.*" -version = "1.1.1" [[package]] -category = "dev" -description = "McCabe checker, plugin for flake8" name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" optional = false python-versions = "*" -version = "0.6.1" [[package]] -category = "main" -description = "multidict implementation" name = "multidict" +version = "4.7.6" +description = "multidict implementation" +category = "main" optional = false python-versions = ">=3.5" -version = "4.7.6" [[package]] -category = "dev" -description = "A Python Parser" -marker = "python_version >= \"3.4\"" 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.*" -version = "0.7.0" +marker = "python_version >= \"3.4\"" [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.0" +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" +marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" [package.dependencies] ptyprocess = ">=0.5" [[package]] -category = "dev" -description = "Tiny 'shelve'-like database with concurrency support" -marker = "python_version >= \"3.4\"" name = "pickleshare" -optional = false -python-versions = "*" version = "0.7.5" - -[[package]] -category = "main" -description = "A pure-Python implementation of the HTTP/2 priority tree" -name = "priority" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" +optional = false +python-versions = "*" +marker = "python_version >= \"3.4\"" + +[[package]] +name = "priority" +version = "1.3.0" +description = "A pure-Python implementation of the HTTP/2 priority tree" +category = "main" optional = false python-versions = "*" -version = "1.3.0" [[package]] -category = "dev" -description = "Library for building powerful interactive command lines in Python" -marker = "python_version >= \"3.4\"" name = "prompt-toolkit" +version = "3.0.7" +description = "Library for building powerful interactive command lines in Python" +category = "dev" optional = false python-versions = ">=3.6.1" -version = "3.0.5" +marker = "python_version >= \"3.4\"" [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" +marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" [[package]] -category = "dev" -description = "Python style guide checker" 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.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" optional = false python-versions = ">=3.5" -version = "2.6.1" +marker = "python_version >= \"3.4\"" [[package]] -category = "main" -description = "A Python ASGI web microframework with the same API as Flask" name = "quart" +version = "0.11.5" +description = "A Python ASGI web microframework with the same API as Flask" +category = "main" optional = false python-versions = ">=3.7.0" -version = "0.11.5" + +[package.extras] +dotenv = ["python-dotenv"] [package.dependencies] aiofiles = "*" @@ -478,46 +481,40 @@ jinja2 = "*" toml = "*" werkzeug = ">=1.0.0" -[package.extras] -dotenv = ["python-dotenv"] - [[package]] -category = "dev" -description = "Alternative regular expression module, to replace re." name = "regex" +version = "2020.7.14" +description = "Alternative regular expression module, to replace re." +category = "dev" optional = false python-versions = "*" -version = "2020.7.14" [[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]] -category = "main" -description = "Structured Logging for Python" name = "structlog" +version = "20.1.0" +description = "Structured Logging for Python" +category = "main" optional = false python-versions = "*" -version = "20.1.0" - -[package.dependencies] -six = "*" [package.extras] azure-pipelines = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "pytest-azurepipelines", "python-rapidjson", "pytest-asyncio"] @@ -525,107 +522,113 @@ dev = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simple docs = ["sphinx", "twisted"] tests = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "python-rapidjson", "pytest-asyncio"] -[[package]] -category = "main" -description = "Python Library for Tom's Obvious, Minimal Language" -name = "toml" -optional = false -python-versions = "*" -version = "0.10.1" - -[[package]] -category = "dev" -description = "Traitlets Python config system" -marker = "python_version >= \"3.4\"" -name = "traitlets" -optional = false -python-versions = "*" -version = "4.3.3" - [package.dependencies] -decorator = "*" -ipython-genutils = "*" six = "*" -[package.extras] -test = ["pytest", "mock"] - -[[package]] -category = "dev" -description = "a fork of Python 2 and 3 ast modules with type comment support" -name = "typed-ast" -optional = false -python-versions = "*" -version = "1.4.1" - [[package]] +name = "toml" +version = "0.10.1" +description = "Python Library for Tom's Obvious, Minimal Language" category = "main" -description = "Backported and Experimental Type Hints for Python 3.5+" -name = "typing-extensions" optional = false python-versions = "*" -version = "3.7.4.2" [[package]] +name = "traitlets" +version = "5.0.4" +description = "Traitlets Python configuration system" category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.7" marker = "python_version >= \"3.4\"" -name = "wcwidth" -optional = false -python-versions = "*" -version = "0.2.5" + +[package.extras] +test = ["pytest"] + +[package.dependencies] +ipython-genutils = "*" [[package]] +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 = "*" + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" -description = "The comprehensive WSGI web application library." +optional = false +python-versions = "*" + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" +marker = "python_version >= \"3.4\"" + +[[package]] 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 = "main" -description = "WebSockets state-machine based protocol implementation" name = "wsproto" +version = "0.15.0" +description = "WebSockets state-machine based protocol implementation" +category = "main" optional = false python-versions = ">=3.6.1" -version = "0.15.0" [package.dependencies] h11 = ">=0.8.1" [[package]] -category = "main" -description = "Yet another URL library" name = "yarl" +version = "1.5.1" +description = "Yet another URL library" +category = "main" optional = false python-versions = ">=3.5" -version = "1.4.2" [package.dependencies] idna = ">=2.0" multidict = ">=4.0" +[package.dependencies.typing-extensions] +version = ">=3.7.4" +python = "<3.8" + [[package]] -category = "dev" -description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version < \"3.8\"" name = "zipp" +version = "3.1.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" optional = false python-versions = ">=3.6" -version = "3.1.0" +marker = "python_version < \"3.8\"" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "ceba58ecf5ec2b4dd95cc58ca7122659610ad89052f420d2e9f22223aff38dd4" +lock-version = "1.0" python-versions = "^3.7" +content-hash = "ceba58ecf5ec2b4dd95cc58ca7122659610ad89052f420d2e9f22223aff38dd4" [metadata.files] aiofiles = [ @@ -659,8 +662,8 @@ async-timeout = [ {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, ] attrs = [ - {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, - {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, + {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, + {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, ] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, @@ -694,8 +697,8 @@ flake8 = [ {file = "flake8-3.8.3.tar.gz", hash = "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"}, ] h11 = [ - {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, - {file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"}, + {file = "h11-0.10.0-py2.py3-none-any.whl", hash = "sha256:9eecfbafc980976dbff26a01dd3487644dd5d00f8038584451fc64a660f7c502"}, + {file = "h11-0.10.0.tar.gz", hash = "sha256:311dc5478c2568cc07262e0381cdfc5b9c6ba19775905736c87e81ae6662b9fd"}, ] h2 = [ {file = "h2-3.2.0-py2.py3-none-any.whl", hash = "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5"}, @@ -706,8 +709,8 @@ hpack = [ {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"}, ] hypercorn = [ - {file = "Hypercorn-0.10.1-py3-none-any.whl", hash = "sha256:728722914548f3ef1b2dded96a4c531bcc743c6bc8b549060a24255ddce2c0ad"}, - {file = "Hypercorn-0.10.1.tar.gz", hash = "sha256:e3473eb1e4187b2468bd71eff5973736fc87a9bf49974da05925eb4ebed5aaff"}, + {file = "Hypercorn-0.10.2-py3-none-any.whl", hash = "sha256:809d77f3bf9fa0794a598d8dfa0f8d889e7e1c2f927581cd33068803169dc474"}, + {file = "Hypercorn-0.10.2.tar.gz", hash = "sha256:19f32e7267225c8108ad585b2c5deddf1fe75950797a0e87a682a3a00ef1af95"}, ] hyperframe = [ {file = "hyperframe-5.2.0-py2.py3-none-any.whl", hash = "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40"}, @@ -725,8 +728,8 @@ ipdb = [ {file = "ipdb-0.13.3.tar.gz", hash = "sha256:d6f46d261c45a65e65a2f7ec69288a1c511e16206edb2875e7ec6b2f66997e78"}, ] ipython = [ - {file = "ipython-7.16.1-py3-none-any.whl", hash = "sha256:2dbcc8c27ca7d3cfe4fcdff7f45b27f9a8d3edfa70ff8024a71c7a8eb5f09d64"}, - {file = "ipython-7.16.1.tar.gz", hash = "sha256:9f4fcb31d3b2c533333893b9172264e4821c1ac91839500f31bd43f2c59b3ccf"}, + {file = "ipython-7.18.1-py3-none-any.whl", hash = "sha256:2e22c1f74477b5106a6fb301c342ab8c64bb75d702e350f05a649e8cb40a0fb8"}, + {file = "ipython-7.18.1.tar.gz", hash = "sha256:a331e78086001931de9424940699691ad49dfb457cea31f5471eae7b78222d5e"}, ] ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, @@ -807,8 +810,8 @@ multidict = [ {file = "multidict-4.7.6.tar.gz", hash = "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430"}, ] parso = [ - {file = "parso-0.7.0-py2.py3-none-any.whl", hash = "sha256:158c140fc04112dc45bca311633ae5033c2c2a7b732fa33d0955bad8152a8dd0"}, - {file = "parso-0.7.0.tar.gz", hash = "sha256:908e9fae2144a076d72ae4e25539143d40b8e3eafbaeae03c1bfe226f4cdf12c"}, + {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"}, @@ -827,8 +830,8 @@ priority = [ {file = "priority-1.3.0.tar.gz", hash = "sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe"}, ] prompt-toolkit = [ - {file = "prompt_toolkit-3.0.5-py3-none-any.whl", hash = "sha256:df7e9e63aea609b1da3a65641ceaf5bc7d05e0a04de5bd45d05dbeffbabf9e04"}, - {file = "prompt_toolkit-3.0.5.tar.gz", hash = "sha256:563d1a4140b63ff9dd587bda9557cffb2fe73650205ab6f4383092fb882e7dc8"}, + {file = "prompt_toolkit-3.0.7-py3-none-any.whl", hash = "sha256:83074ee28ad4ba6af190593d4d4c607ff525272a504eb159199b6dd9f950c950"}, + {file = "prompt_toolkit-3.0.7.tar.gz", hash = "sha256:822f4605f28f7d2ba6b0b09a31e25e140871e96364d1d377667b547bb3bf4489"}, ] ptyprocess = [ {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"}, @@ -843,8 +846,8 @@ pyflakes = [ {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] pygments = [ - {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, - {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, + {file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"}, + {file = "Pygments-2.7.1.tar.gz", hash = "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"}, ] quart = [ {file = "Quart-0.11.5-py3-none-any.whl", hash = "sha256:187427d1a2d7fed20dcb825dddbe20fd971efd7ec413639f95d2e28ff59a0cb1"}, @@ -868,6 +871,7 @@ regex = [ {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"}, @@ -890,8 +894,8 @@ toml = [ {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, ] traitlets = [ - {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"}, - {file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"}, + {file = "traitlets-5.0.4-py3-none-any.whl", hash = "sha256:9664ec0c526e48e7b47b7d14cd6b252efa03e0129011de0a9c1d70315d4309c3"}, + {file = "traitlets-5.0.4.tar.gz", hash = "sha256:86c9351f94f95de9db8a04ad8e892da299a088a64fd283f9f6f18770ae5eae1b"}, ] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, @@ -917,9 +921,9 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, - {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, - {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, @@ -934,23 +938,23 @@ wsproto = [ {file = "wsproto-0.15.0.tar.gz", hash = "sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d"}, ] yarl = [ - {file = "yarl-1.4.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b"}, - {file = "yarl-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1"}, - {file = "yarl-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080"}, - {file = "yarl-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a"}, - {file = "yarl-1.4.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f"}, - {file = "yarl-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea"}, - {file = "yarl-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb"}, - {file = "yarl-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70"}, - {file = "yarl-1.4.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d"}, - {file = "yarl-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce"}, - {file = "yarl-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"}, - {file = "yarl-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce"}, - {file = "yarl-1.4.2-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b"}, - {file = "yarl-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae"}, - {file = "yarl-1.4.2-cp38-cp38-win32.whl", hash = "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462"}, - {file = "yarl-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6"}, - {file = "yarl-1.4.2.tar.gz", hash = "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b"}, + {file = "yarl-1.5.1-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb"}, + {file = "yarl-1.5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593"}, + {file = "yarl-1.5.1-cp35-cp35m-win32.whl", hash = "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409"}, + {file = "yarl-1.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317"}, + {file = "yarl-1.5.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511"}, + {file = "yarl-1.5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e"}, + {file = "yarl-1.5.1-cp36-cp36m-win32.whl", hash = "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f"}, + {file = "yarl-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2"}, + {file = "yarl-1.5.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a"}, + {file = "yarl-1.5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8"}, + {file = "yarl-1.5.1-cp37-cp37m-win32.whl", hash = "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8"}, + {file = "yarl-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d"}, + {file = "yarl-1.5.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02"}, + {file = "yarl-1.5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a"}, + {file = "yarl-1.5.1-cp38-cp38-win32.whl", hash = "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"}, + {file = "yarl-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692"}, + {file = "yarl-1.5.1.tar.gz", hash = "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6"}, ] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, From d2857dc412c473e057ada0ed6cf06f04ff821b88 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 16:12:56 +0200 Subject: [PATCH 18/44] Updated Makefile to use podman --- Makefile | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index cf6f0a8..80bebb2 100644 --- a/Makefile +++ b/Makefile @@ -2,27 +2,23 @@ setup: poetry install -docker@build: - docker build -t fmartingr/butterrobot -f docker/Dockerfile docker +podman@build: + podman build -t fmartingr/butterrobot -f docker/Dockerfile docker -docker@build-dev: - docker build -t fmartingr/butterrobot:dev -f Dockerfile.dev . +podman@build-dev: + podman build -t fmartingr/butterrobot:dev -f Dockerfile.dev . -docker@tag-dev: - docker tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev +podman@tag-dev: + podman tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev -docker@push-dev: +podman@push-dev: docker push registry.int.fmartingr.network/fmartingr/butterrobot:dev -docker@dev: - make docker@build-dev - make docker@tag-dev +podman@dev: + make podman@build-dev + make podman@tag-dev make docker@push-dev -docker@save: - make docker@build - docker image save fmartingr/butterrobot -o fmartingr-butterrobot-docker-image.tar - clean: rm -rf dist rm -rf butterrobot.egg-info From 3d2b05db0f57866493dc74d9f12943bcfcf8ece9 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 20:16:21 +0200 Subject: [PATCH 19/44] Typo --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 80bebb2..4386b09 100644 --- a/Makefile +++ b/Makefile @@ -12,12 +12,12 @@ podman@tag-dev: podman tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev podman@push-dev: - docker push registry.int.fmartingr.network/fmartingr/butterrobot:dev + podman push registry.int.fmartingr.network/fmartingr/butterrobot:dev podman@dev: make podman@build-dev make podman@tag-dev - make docker@push-dev + make podman@push-dev clean: rm -rf dist From 9921d067ffa03852d9ea129a2588946a02497a1e Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 20:16:29 +0200 Subject: [PATCH 20/44] Typo --- butterrobot/platforms/telegram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py index a83c9b6..6e603e1 100644 --- a/butterrobot/platforms/telegram.py +++ b/butterrobot/platforms/telegram.py @@ -61,7 +61,7 @@ class TelegramPlatform(Platform): 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"]["from_bot"], + from_bot=request_data["message"]["from"]["is_bot"], author=request_data["message"]["from"]["id"], chat=str(request_data["message"]["chat"]["id"]), raw=request_data, From 903076de547621f20765b724fae2952952607d09 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Thu, 17 Sep 2020 20:16:42 +0200 Subject: [PATCH 21/44] Added !dice command --- README.md | 8 +++--- butterrobot_plugins_contrib/fun.py | 12 +++++++++ poetry.lock | 41 +++++++++++++++++++++++++++++- pyproject.toml | 2 ++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9e4a687..24823b7 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,13 @@ Python framework to create bots for several platforms. ### Development -#### Ping - - Say `!ping` to get response with time elapsed. +- `!ping`: Say `!ping` to get response with time elapsed. ### Fun and entertainment -#### Loquito - What happens when you say _"lo quito"_...? (Spanish pun) +- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) +- Dice: Put `!dice` and wathever roll you want to perform. ## Installation diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py index 70f9c6c..97502dc 100644 --- a/butterrobot_plugins_contrib/fun.py +++ b/butterrobot_plugins_contrib/fun.py @@ -1,3 +1,5 @@ +import dice + from butterrobot.plugins import Plugin from butterrobot.objects import Message @@ -9,3 +11,13 @@ class LoquitoPlugin(Plugin): async def on_message(cls, message): if "lo quito" in message.text.lower(): yield Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",) + + +class DicePlugin(Plugin): + id = "contrib/fun/dice" + + @classmethod + async def on_message(cls, message: Message): + if message.text.startswith("!dice"): + roll = int(dice.roll(message.text.replace("!dice ", ""))) + yield Message(chat=message.chat, reply_to=message.id, text=roll) \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 02c7c2d..2e3a057 100644 --- a/poetry.lock +++ b/poetry.lock @@ -133,6 +133,26 @@ optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" marker = "python_version >= \"3.4\"" +[[package]] +name = "dice" +version = "3.1.0" +description = "A library for parsing and evaluating dice notation" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +docopt = ">=0.6.1" +pyparsing = ">=2.4.1" + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "flake8" version = "3.8.3" @@ -460,6 +480,14 @@ optional = false python-versions = ">=3.5" marker = "python_version >= \"3.4\"" +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "quart" version = "0.11.5" @@ -628,7 +656,7 @@ testing = ["jaraco.itertools", "func-timeout"] [metadata] lock-version = "1.0" python-versions = "^3.7" -content-hash = "ceba58ecf5ec2b4dd95cc58ca7122659610ad89052f420d2e9f22223aff38dd4" +content-hash = "d5b0608322fa2ad2850a96e7959933dc45beba7feb265345a922e501f805f908" [metadata.files] aiofiles = [ @@ -692,6 +720,13 @@ 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"}, +] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] flake8 = [ {file = "flake8-3.8.3-py2.py3-none-any.whl", hash = "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c"}, {file = "flake8-3.8.3.tar.gz", hash = "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"}, @@ -849,6 +884,10 @@ pygments = [ {file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"}, {file = "Pygments-2.7.1.tar.gz", hash = "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"}, ] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] quart = [ {file = "Quart-0.11.5-py3-none-any.whl", hash = "sha256:187427d1a2d7fed20dcb825dddbe20fd971efd7ec413639f95d2e28ff59a0cb1"}, {file = "Quart-0.11.5.tar.gz", hash = "sha256:bd93650fa856dcfbc3890952ab3ca53f7755ab506d453a209db63713eceeceda"}, diff --git a/pyproject.toml b/pyproject.toml index 0bcd2c8..e6faacd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ quart = "^0.11.3" aiohttp = "^3.6.2" structlog = "^20.1.0" colorama = "^0.4.3" +dice = "^3.1.0" [tool.poetry.dev-dependencies] black = "^19.10b0" @@ -29,6 +30,7 @@ ipdb = "^0.13.2" [tool.poetry.plugins."butterrobot.plugins"] "fun.loquito" = "butterrobot_plugins_contrib.fun:LoquitoPlugin" "dev.ping" = "butterrobot_plugins_contrib.dev:PingPlugin" +"fun.dice" = "butterrobot_plugins_contrib.fun:DicePlugin" [build-system] requires = ["poetry>=0.12"] From 62fb0ec8d4dc92eef89ca6ead1ef742446b97c1c Mon Sep 17 00:00:00 2001 From: Felipe Martin Garcia Date: Fri, 25 Sep 2020 21:37:56 +0000 Subject: [PATCH 22/44] Ignore codespaces python env --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6054505..411e90d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ test.py dist *.egg-info pip-wheel-metadata + +# Github Codespaces +pythonenv3.8 \ No newline at end of file From 6d3ad1429835e7ce6f5ea7beb3c428779345edba Mon Sep 17 00:00:00 2001 From: Felipe M Date: Wed, 28 Oct 2020 11:19:30 +0100 Subject: [PATCH 23/44] Removed async code --- butterrobot/app.py | 24 +- butterrobot/lib/slack.py | 15 +- butterrobot/lib/telegram.py | 25 +- butterrobot/platforms/base.py | 2 +- butterrobot/platforms/debug.py | 6 +- butterrobot/platforms/slack.py | 10 +- butterrobot/platforms/telegram.py | 12 +- butterrobot/plugins.py | 2 +- butterrobot_plugins_contrib/dev.py | 2 +- butterrobot_plugins_contrib/fun.py | 4 +- poetry.lock | 677 +++++++++++------------------ pyproject.toml | 4 +- 12 files changed, 296 insertions(+), 487 deletions(-) diff --git a/butterrobot/app.py b/butterrobot/app.py index 95b95a7..3759dc8 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -1,7 +1,6 @@ -import asyncio import traceback -from quart import Quart, request +from flask import Flask, request import structlog import butterrobot.logging # noqa @@ -13,7 +12,7 @@ from butterrobot.platforms.base import Platform logger = structlog.get_logger(__name__) -app = Quart(__name__) +app = Flask(__name__) available_platforms = {} plugins = get_available_plugins() enabled_plugins = [ @@ -21,18 +20,18 @@ enabled_plugins = [ ] -async def handle_message(platform: str, message: Message): +def handle_message(platform: str, message: Message): for plugin in enabled_plugins: - async for response_message in plugin.on_message(message): - asyncio.ensure_future(available_platforms[platform].methods.send_message(response_message)) + for response_message in plugin.on_message(message): + available_platforms[platform].methods.send_message(response_message) -@app.before_serving -async def init_platforms(): +@app.before_first_request +def init_platforms(): for platform in PLATFORMS.values(): logger.debug("Setting up", platform=platform.ID) try: - await platform.init(app=app) + platform.init(app=app) available_platforms[platform.ID] = platform logger.info("platform setup completed", platform=platform.ID) except platform.PlatformInitError as error: @@ -41,12 +40,12 @@ async def init_platforms(): @app.route("//incoming", methods=["POST"]) @app.route("//incoming/", methods=["POST"]) -async def incoming_platform_message_view(platform, path=None): +def incoming_platform_message_view(platform, path=None): if platform not in available_platforms: return {"error": "Unknown platform"}, 400 try: - message = await available_platforms[platform].parse_incoming_message( + message = available_platforms[platform].parse_incoming_message( request=request ) except Platform.PlatformAuthResponse as response: @@ -63,7 +62,8 @@ async def incoming_platform_message_view(platform, path=None): if not message or message.from_bot: return {} - asyncio.ensure_future(handle_message(platform, message)) + # TODO: make with rq/dramatiq + handle_message(platform, message) return {} diff --git a/butterrobot/lib/slack.py b/butterrobot/lib/slack.py index 48eb8dd..e0dbbd1 100644 --- a/butterrobot/lib/slack.py +++ b/butterrobot/lib/slack.py @@ -1,6 +1,6 @@ from typing import Optional, Text -import aiohttp +import requests import structlog from butterrobot.config import SLACK_BOT_OAUTH_ACCESS_TOKEN @@ -19,7 +19,7 @@ class SlackAPI: pass @classmethod - async def send_message(cls, channel, message, thread: Optional[Text] = None): + def send_message(cls, channel, message, thread: Optional[Text] = None): payload = { "text": message, "channel": channel, @@ -28,12 +28,11 @@ class SlackAPI: if thread: payload["thread_ts"] = thread - async with aiohttp.ClientSession() as session: - async with session.post( + response = requestts.post( f"{cls.BASE_URL}/chat.postMessage", data=payload, headers={"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"}, - ) as response: - response = await response.json() - if not response["ok"]: - raise cls.SlackClientError(response) + ) + 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 ac2bd21..2b9fb45 100644 --- a/butterrobot/lib/telegram.py +++ b/butterrobot/lib/telegram.py @@ -1,4 +1,4 @@ -import aiohttp +import requests import structlog from butterrobot.config import TELEGRAM_TOKEN @@ -19,7 +19,7 @@ class TelegramAPI: pass @classmethod - async def set_webhook(cls, webhook_url, max_connections=40, allowed_updates=None): + def set_webhook(cls, webhook_url, max_connections=40, allowed_updates=None): allowed_updates = allowed_updates or cls.DEFAULT_ALLOWED_UPDATES url = f"{cls.BASE_URL}/setWebhook" payload = { @@ -27,14 +27,13 @@ class TelegramAPI: "max_connections": max_connections, "allowed_updates": allowed_updates, } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload) as response: - response = await response.json() - if not response["ok"]: - raise cls.TelegramClientError(response) + response = requests.post(url, json=payload) + response_json = response.json() + if not response_json["ok"]: + raise cls.TelegramClientError(response_json) @classmethod - async def send_message( + def send_message( cls, chat_id, text, @@ -52,8 +51,8 @@ class TelegramAPI: "disable_notification": disable_notification, "reply_to_message_id": reply_to_message_id, } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload) as response: - response = await response.json() - if not response["ok"]: - raise cls.TelegramClientError(response) + + 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 diff --git a/butterrobot/platforms/base.py b/butterrobot/platforms/base.py index 8fa2198..1201739 100644 --- a/butterrobot/platforms/base.py +++ b/butterrobot/platforms/base.py @@ -21,7 +21,7 @@ class Platform: status_code: int = 200 @classmethod - async def init(cls, app): + def init(cls, app): pass diff --git a/butterrobot/platforms/debug.py b/butterrobot/platforms/debug.py index 3d59230..6faacfe 100644 --- a/butterrobot/platforms/debug.py +++ b/butterrobot/platforms/debug.py @@ -12,7 +12,7 @@ logger = structlog.get_logger(__name__) class DebugMethods(PlatformMethods): @classmethod - async def send_message(self, message: Message): + def send_message(self, message: Message): logger.debug( "Outgoing message", message=message.__dict__, platform=DebugPlatform.ID ) @@ -24,8 +24,8 @@ class DebugPlatform(Platform): methods = DebugMethods @classmethod - async def parse_incoming_message(cls, request): - request_data = await request.get_json() + def parse_incoming_message(cls, request): + request_data = request.get_json() logger.debug("Parsing message", data=request_data, platform=cls.ID) return Message( diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py index 3bb2e21..8a77a01 100644 --- a/butterrobot/platforms/slack.py +++ b/butterrobot/platforms/slack.py @@ -13,12 +13,12 @@ logger = structlog.get_logger(__name__) class SlackMethods(PlatformMethods): @classmethod - async def send_message(self, message: Message): + def send_message(self, message: Message): logger.debug( "Outgoing message", message=message.__dict__, platform=SlackPlatform.ID ) try: - await SlackAPI.send_message( + SlackAPI.send_message( channel=message.chat, message=message.text, thread=message.reply_to ) except SlackAPI.SlackClientError as error: @@ -36,14 +36,14 @@ class SlackPlatform(Platform): methods = SlackMethods @classmethod - async def init(cls, app): + def init(cls, app): if not (SLACK_TOKEN and SLACK_BOT_OAUTH_ACCESS_TOKEN): logger.error("Missing token. platform not enabled.", platform=cls.ID) return @classmethod - async def parse_incoming_message(cls, request): - data = await request.get_json() + def parse_incoming_message(cls, request): + data = request.get_json() # Auth if data.get("token") != SLACK_TOKEN: diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py index 6e603e1..13583fe 100644 --- a/butterrobot/platforms/telegram.py +++ b/butterrobot/platforms/telegram.py @@ -13,11 +13,11 @@ logger = structlog.get_logger(__name__) class TelegramMethods(PlatformMethods): @classmethod - async def send_message(self, message: Message): + def send_message(self, message: Message): logger.debug( "Outgoing message", message=message.__dict__, platform=TelegramPlatform.ID ) - await TelegramAPI.send_message( + TelegramAPI.send_message( chat_id=message.chat, text=message.text, reply_to_message_id=message.reply_to, @@ -30,7 +30,7 @@ class TelegramPlatform(Platform): methods = TelegramMethods @classmethod - async def init(cls, app): + def init(cls, app): """ Initializes the Telegram webhook endpoint to receive updates """ @@ -41,18 +41,18 @@ class TelegramPlatform(Platform): webhook_url = f"https://{HOSTNAME}/telegram/incoming/{TELEGRAM_TOKEN}" try: - await TelegramAPI.set_webhook(webhook_url) + TelegramAPI.set_webhook(webhook_url) except TelegramAPI.TelegramError as error: logger.error(f"Error setting Telegram webhook: {error}", platform=cls.ID) raise Platform.PlatformInitError() @classmethod - async def parse_incoming_message(cls, request): + def parse_incoming_message(cls, request): token = request.path.split("/")[-1] if token != TELEGRAM_TOKEN: raise cls.PlatformAuthError("Authentication error") - request_data = await request.get_json() + request_data = request.get_json() logger.debug("Parsing message", data=request_data, platform=cls.ID) if "text" in request_data["message"]: diff --git a/butterrobot/plugins.py b/butterrobot/plugins.py index b2752e8..514c69e 100644 --- a/butterrobot/plugins.py +++ b/butterrobot/plugins.py @@ -11,7 +11,7 @@ logger = structlog.get_logger(__name__) class Plugin: @abstractclassmethod - async def on_message(cls, message: Message): + def on_message(cls, message: Message): pass diff --git a/butterrobot_plugins_contrib/dev.py b/butterrobot_plugins_contrib/dev.py index fec9833..c4641cb 100644 --- a/butterrobot_plugins_contrib/dev.py +++ b/butterrobot_plugins_contrib/dev.py @@ -8,7 +8,7 @@ class PingPlugin(Plugin): id = "contrib/dev/ping" @classmethod - async def on_message(cls, message): + def on_message(cls, message): 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 97502dc..8aa0de6 100644 --- a/butterrobot_plugins_contrib/fun.py +++ b/butterrobot_plugins_contrib/fun.py @@ -8,7 +8,7 @@ class LoquitoPlugin(Plugin): id = "contrib/fun/loquito" @classmethod - async def on_message(cls, message): + def on_message(cls, message): if "lo quito" in message.text.lower(): yield Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",) @@ -17,7 +17,7 @@ class DicePlugin(Plugin): id = "contrib/fun/dice" @classmethod - async def on_message(cls, message: Message): + def on_message(cls, message: Message): if message.text.startswith("!dice"): roll = int(dice.roll(message.text.replace("!dice ", ""))) yield Message(chat=message.chat, reply_to=message.id, text=roll) \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 2e3a057..056df31 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,61 +1,27 @@ [[package]] -name = "aiofiles" -version = "0.5.0" -description = "File support for asyncio." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "aiohttp" -version = "3.6.2" -description = "Async http client/server framework (asyncio)" -category = "main" -optional = false -python-versions = ">=3.5.3" - -[package.extras] -speedups = ["aiodns", "brotlipy", "cchardet"] - -[package.dependencies] -async-timeout = ">=3.0,<4.0" -attrs = ">=17.3.0" -chardet = ">=2.0,<4.0" -multidict = ">=4.5,<5.0" -yarl = ">=1.0,<2.0" - -[[package]] -name = "appdirs" -version = "1.4.4" +category = "dev" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" +name = "appdirs" optional = false python-versions = "*" +version = "1.4.4" [[package]] -name = "appnope" -version = "0.1.0" +category = "dev" description = "Disable App Nap on OS X 10.9" -category = "dev" +marker = "python_version >= \"3.4\" and sys_platform == \"darwin\"" +name = "appnope" optional = false python-versions = "*" -marker = "python_version >= \"3.4\" and sys_platform == \"darwin\"" +version = "0.1.0" [[package]] -name = "async-timeout" -version = "3.0.1" -description = "Timeout context manager for asyncio programs" -category = "main" -optional = false -python-versions = ">=3.5.3" - -[[package]] -name = "attrs" -version = "20.2.0" +category = "dev" description = "Classes Without Boilerplate" -category = "main" +name = "attrs" 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"] @@ -64,24 +30,21 @@ tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six tests_no_zope = ["coverage (>=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" +description = "Specifications for callback functions passed in to an API" +marker = "python_version >= \"3.4\"" +name = "backcall" optional = false python-versions = "*" -marker = "python_version >= \"3.4\"" +version = "0.2.0" [[package]] -name = "black" -version = "19.10b0" -description = "The uncompromising code formatter." category = "dev" +description = "The uncompromising code formatter." +name = "black" optional = false python-versions = ">=3.6" - -[package.extras] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +version = "19.10b0" [package.dependencies] appdirs = "*" @@ -92,74 +55,77 @@ regex = "*" toml = ">=0.9.4" typed-ast = ">=1.4.0" -[[package]] -name = "blinker" -version = "1.4" -description = "Fast, simple object-to-object and broadcast signaling" -category = "main" -optional = false -python-versions = "*" +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] -name = "chardet" -version = "3.0.4" +category = "main" +description = "Python package for providing Mozilla's CA Bundle." +name = "certifi" +optional = false +python-versions = "*" +version = "2020.6.20" + +[[package]] +category = "main" description = "Universal encoding detector for Python 2 and 3" -category = "main" +name = "chardet" optional = false python-versions = "*" +version = "3.0.4" [[package]] -name = "click" -version = "7.1.2" +category = "main" description = "Composable command line interface toolkit" -category = "main" +name = "click" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "7.1.2" [[package]] -name = "colorama" -version = "0.4.3" +category = "main" description = "Cross-platform colored terminal text." -category = "main" +name = "colorama" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" [[package]] -name = "decorator" -version = "4.4.2" -description = "Decorators for Humans" category = "dev" +description = "Decorators for Humans" +marker = "python_version >= \"3.4\"" +name = "decorator" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" -marker = "python_version >= \"3.4\"" +version = "4.4.2" [[package]] -name = "dice" -version = "3.1.0" -description = "A library for parsing and evaluating dice notation" category = "main" +description = "A library for parsing and evaluating dice notation" +name = "dice" optional = false python-versions = "*" +version = "3.1.0" [package.dependencies] docopt = ">=0.6.1" pyparsing = ">=2.4.1" [[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" category = "main" +description = "Pythonic argument parser, that will make you smile" +name = "docopt" optional = false python-versions = "*" +version = "0.6.2" [[package]] -name = "flake8" -version = "3.8.3" -description = "the modular source code checker: pep8 pyflakes and co" category = "dev" +description = "the modular source code checker: pep8 pyflakes and co" +name = "flake8" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "3.8.3" [package.dependencies] mccabe = ">=0.6.0,<0.7.0" @@ -167,125 +133,75 @@ pycodestyle = ">=2.6.0a1,<2.7.0" pyflakes = ">=2.2.0,<2.3.0" [package.dependencies.importlib-metadata] -version = "*" python = "<3.8" +version = "*" [[package]] -name = "h11" -version = "0.10.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" category = "main" +description = "A simple framework for building complex web applications." +name = "flask" optional = false -python-versions = "*" - -[[package]] -name = "h2" -version = "3.2.0" -description = "HTTP/2 State-Machine based protocol implementation" -category = "main" -optional = false -python-versions = "*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "1.1.2" [package.dependencies] -hpack = ">=3.0,<4" -hyperframe = ">=5.2.0,<6" - -[[package]] -name = "hpack" -version = "3.0.0" -description = "Pure-Python HPACK header compression" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "hypercorn" -version = "0.10.2" -description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn." -category = "main" -optional = false -python-versions = ">=3.7" +Jinja2 = ">=2.10.1" +Werkzeug = ">=0.15" +click = ">=5.1" +itsdangerous = ">=0.24" [package.extras] -h3 = ["aioquic (>=0.9.0,<1.0)"] -tests = ["hypothesis", "mock", "pytest", "pytest-asyncio", "pytest-cov", "pytest-trio", "trio"] -trio = ["trio (>=0.11.0)"] -uvloop = ["uvloop"] - -[package.dependencies] -h11 = "*" -h2 = ">=3.1.0" -priority = "*" -toml = "*" -typing-extensions = "*" -wsproto = ">=0.14.0" +dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] +docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] +dotenv = ["python-dotenv"] [[package]] -name = "hyperframe" -version = "5.2.0" -description = "HTTP/2 framing layer for Python" category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "idna" -version = "2.10" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" +name = "idna" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.10" [[package]] -name = "importlib-metadata" -version = "1.7.0" -description = "Read metadata from Python packages" category = "dev" +description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" +name = "importlib-metadata" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -marker = "python_version < \"3.8\"" +version = "1.7.0" + +[package.dependencies] +zipp = ">=0.5" [package.extras] docs = ["sphinx", "rst.linker"] testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] -[package.dependencies] -zipp = ">=0.5" - [[package]] -name = "ipdb" -version = "0.13.3" -description = "IPython-enabled pdb" category = "dev" +description = "IPython-enabled pdb" +name = "ipdb" optional = false python-versions = ">=2.7" +version = "0.13.3" [package.dependencies] setuptools = "*" [package.dependencies.ipython] -version = ">=5.1.0" python = ">=3.4" +version = ">=5.1.0" [[package]] -name = "ipython" -version = "7.18.1" -description = "IPython: Productive Interactive Computing" category = "dev" +description = "IPython: Productive Interactive Computing" +marker = "python_version >= \"3.4\"" +name = "ipython" optional = false python-versions = ">=3.7" -marker = "python_version >= \"3.4\"" - -[package.extras] -all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] -doc = ["Sphinx (>=1.3)"] -kernel = ["ipykernel"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["notebook", "ipywidgets"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"] +version = "7.18.1" [package.dependencies] appnope = "*" @@ -300,22 +216,33 @@ pygments = "*" setuptools = ">=18.5" traitlets = ">=4.2" -[[package]] -name = "ipython-genutils" -version = "0.2.0" -description = "Vestigial utilities from IPython" -category = "dev" -optional = false -python-versions = "*" -marker = "python_version >= \"3.4\"" +[package.extras] +all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["notebook", "ipywidgets"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"] [[package]] -name = "isort" -version = "4.3.21" -description = "A Python utility / library to sort Python imports." category = "dev" +description = "Vestigial utilities from IPython" +marker = "python_version >= \"3.4\"" +name = "ipython-genutils" +optional = false +python-versions = "*" +version = "0.2.0" + +[[package]] +category = "dev" +description = "A Python utility / library to sort Python imports." +name = "isort" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "4.3.21" [package.extras] pipfile = ["pipreqs", "requirementslib"] @@ -324,225 +251,209 @@ requirements = ["pipreqs", "pip-api"] xdg_home = ["appdirs (>=1.4.0)"] [[package]] -name = "itsdangerous" -version = "1.1.0" -description = "Various helpers to pass data to untrusted environments and back." category = "main" +description = "Various helpers to pass data to untrusted environments and back." +name = "itsdangerous" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.1.0" [[package]] -name = "jedi" -version = "0.17.2" -description = "An autocompletion tool for Python that can be used for text editors." category = "dev" +description = "An autocompletion tool for Python that can be used for text editors." +marker = "python_version >= \"3.4\"" +name = "jedi" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -marker = "python_version >= \"3.4\"" +version = "0.17.2" + +[package.dependencies] +parso = ">=0.7.0,<0.8.0" [package.extras] qa = ["flake8 (3.7.9)"] testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] -[package.dependencies] -parso = ">=0.7.0,<0.8.0" - [[package]] -name = "jinja2" -version = "2.11.2" -description = "A very fast and expressive template engine." category = "main" +description = "A very fast and expressive template engine." +name = "jinja2" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.extras] -i18n = ["Babel (>=0.8)"] +version = "2.11.2" [package.dependencies] MarkupSafe = ">=0.23" +[package.extras] +i18n = ["Babel (>=0.8)"] + [[package]] -name = "markupsafe" -version = "1.1.1" -description = "Safely add untrusted strings to HTML/XML markup." 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]] -name = "mccabe" -version = "0.6.1" -description = "McCabe checker, plugin for flake8" category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" optional = false python-versions = "*" +version = "0.6.1" [[package]] -name = "multidict" -version = "4.7.6" -description = "multidict implementation" -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "parso" -version = "0.7.1" -description = "A Python Parser" 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.*" -marker = "python_version >= \"3.4\"" +version = "0.7.1" [package.extras] testing = ["docopt", "pytest (>=3.0.7)"] [[package]] -name = "pathspec" -version = "0.8.0" -description = "Utility library for gitignore style pattern matching of file paths." category = "dev" +description = "Utility library for gitignore style pattern matching of file paths." +name = "pathspec" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.8.0" [[package]] -name = "pexpect" -version = "4.8.0" -description = "Pexpect allows easy control of interactive console applications." category = "dev" +description = "Pexpect allows easy control of interactive console applications." +marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" +name = "pexpect" optional = false python-versions = "*" -marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" +version = "4.8.0" [package.dependencies] ptyprocess = ">=0.5" [[package]] -name = "pickleshare" -version = "0.7.5" +category = "dev" description = "Tiny 'shelve'-like database with concurrency support" -category = "dev" -optional = false -python-versions = "*" marker = "python_version >= \"3.4\"" - -[[package]] -name = "priority" -version = "1.3.0" -description = "A pure-Python implementation of the HTTP/2 priority tree" -category = "main" +name = "pickleshare" optional = false python-versions = "*" +version = "0.7.5" [[package]] -name = "prompt-toolkit" -version = "3.0.7" -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" -marker = "python_version >= \"3.4\"" +version = "3.0.7" [package.dependencies] wcwidth = "*" [[package]] -name = "ptyprocess" -version = "0.6.0" -description = "Run a subprocess in a pseudo terminal" category = "dev" +description = "Run a subprocess in a pseudo terminal" +marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" +name = "ptyprocess" optional = false python-versions = "*" -marker = "python_version >= \"3.4\" and sys_platform != \"win32\"" +version = "0.6.0" [[package]] -name = "pycodestyle" -version = "2.6.0" +category = "dev" description = "Python style guide checker" -category = "dev" +name = "pycodestyle" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.6.0" [[package]] -name = "pyflakes" -version = "2.2.0" +category = "dev" description = "passive checker of Python programs" -category = "dev" +name = "pyflakes" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.2.0" [[package]] -name = "pygments" -version = "2.7.1" -description = "Pygments is a syntax highlighting package written in Python." category = "dev" +description = "Pygments is a syntax highlighting package written in Python." +marker = "python_version >= \"3.4\"" +name = "pygments" optional = false python-versions = ">=3.5" -marker = "python_version >= \"3.4\"" +version = "2.7.1" [[package]] -name = "pyparsing" -version = "2.4.7" -description = "Python parsing module" category = "main" +description = "Python parsing module" +name = "pyparsing" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.7" [[package]] -name = "quart" -version = "0.11.5" -description = "A Python ASGI web microframework with the same API as Flask" -category = "main" +category = "dev" +description = "Alternative regular expression module, to replace re." +name = "regex" optional = false -python-versions = ">=3.7.0" +python-versions = "*" +version = "2020.7.14" -[package.extras] -dotenv = ["python-dotenv"] +[[package]] +category = "main" +description = "Python HTTP for Humans." +name = "requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.24.0" [package.dependencies] -aiofiles = "*" -blinker = "*" -click = "*" -hypercorn = ">=0.7.0" -itsdangerous = "*" -jinja2 = "*" -toml = "*" -werkzeug = ">=1.0.0" +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" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] [[package]] -name = "regex" -version = "2020.7.14" -description = "Alternative regular expression module, to replace re." category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "rope" -version = "0.16.0" description = "a python refactoring library..." -category = "dev" +name = "rope" optional = false python-versions = "*" +version = "0.16.0" [package.extras] dev = ["pytest"] [[package]] -name = "six" -version = "1.15.0" -description = "Python 2 and 3 compatibility utilities" category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.15.0" [[package]] -name = "structlog" -version = "20.1.0" -description = "Structured Logging for Python" category = "main" +description = "Structured Logging for Python" +name = "structlog" optional = false python-versions = "*" +version = "20.1.0" + +[package.dependencies] +six = "*" [package.extras] azure-pipelines = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "pytest-azurepipelines", "python-rapidjson", "pytest-asyncio"] @@ -550,133 +461,90 @@ dev = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simple docs = ["sphinx", "twisted"] tests = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "python-rapidjson", "pytest-asyncio"] -[package.dependencies] -six = "*" - [[package]] -name = "toml" -version = "0.10.1" +category = "dev" description = "Python Library for Tom's Obvious, Minimal Language" -category = "main" +name = "toml" optional = false python-versions = "*" +version = "0.10.1" [[package]] -name = "traitlets" -version = "5.0.4" -description = "Traitlets Python configuration system" category = "dev" +description = "Traitlets Python configuration system" +marker = "python_version >= \"3.4\"" +name = "traitlets" optional = false python-versions = ">=3.7" -marker = "python_version >= \"3.4\"" - -[package.extras] -test = ["pytest"] +version = "5.0.4" [package.dependencies] ipython-genutils = "*" +[package.extras] +test = ["pytest"] + [[package]] -name = "typed-ast" -version = "1.4.1" +category = "dev" description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" +name = "typed-ast" optional = false python-versions = "*" +version = "1.4.1" [[package]] -name = "typing-extensions" -version = "3.7.4.3" -description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" +description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "urllib3" optional = false -python-versions = "*" +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)"] [[package]] -name = "wcwidth" -version = "0.2.5" +category = "dev" description = "Measures the displayed width of unicode strings in a terminal" -category = "dev" +marker = "python_version >= \"3.4\"" +name = "wcwidth" optional = false python-versions = "*" -marker = "python_version >= \"3.4\"" +version = "0.2.5" [[package]] -name = "werkzeug" -version = "1.0.1" -description = "The comprehensive WSGI web application library." category = "main" +description = "The comprehensive WSGI web application library." +name = "werkzeug" 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]] -name = "wsproto" -version = "0.15.0" -description = "WebSockets state-machine based protocol implementation" -category = "main" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -h11 = ">=0.8.1" - -[[package]] -name = "yarl" -version = "1.5.1" -description = "Yet another URL library" -category = "main" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" - -[package.dependencies.typing-extensions] -version = ">=3.7.4" -python = "<3.8" - -[[package]] -name = "zipp" -version = "3.1.0" -description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" +description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" +name = "zipp" optional = false python-versions = ">=3.6" -marker = "python_version < \"3.8\"" +version = "3.1.0" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] +content-hash = "76fdbeb7b0f4a02bb2ee756204e4579c589cea248bb9b01ddebae4c64ae5d5a3" lock-version = "1.0" python-versions = "^3.7" -content-hash = "d5b0608322fa2ad2850a96e7959933dc45beba7feb265345a922e501f805f908" [metadata.files] -aiofiles = [ - {file = "aiofiles-0.5.0-py3-none-any.whl", hash = "sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb"}, - {file = "aiofiles-0.5.0.tar.gz", hash = "sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"}, -] -aiohttp = [ - {file = "aiohttp-3.6.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e"}, - {file = "aiohttp-3.6.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec"}, - {file = "aiohttp-3.6.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48"}, - {file = "aiohttp-3.6.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59"}, - {file = "aiohttp-3.6.2-cp36-cp36m-win32.whl", hash = "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a"}, - {file = "aiohttp-3.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17"}, - {file = "aiohttp-3.6.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a"}, - {file = "aiohttp-3.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd"}, - {file = "aiohttp-3.6.2-cp37-cp37m-win32.whl", hash = "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"}, - {file = "aiohttp-3.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654"}, - {file = "aiohttp-3.6.2-py3-none-any.whl", hash = "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4"}, - {file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"}, -] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, @@ -685,10 +553,6 @@ appnope = [ {file = "appnope-0.1.0-py2.py3-none-any.whl", hash = "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0"}, {file = "appnope-0.1.0.tar.gz", hash = "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"}, ] -async-timeout = [ - {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, - {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, -] attrs = [ {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, @@ -701,8 +565,9 @@ black = [ {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] -blinker = [ - {file = "blinker-1.4.tar.gz", hash = "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"}, +certifi = [ + {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, + {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, ] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, @@ -731,25 +596,9 @@ flake8 = [ {file = "flake8-3.8.3-py2.py3-none-any.whl", hash = "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c"}, {file = "flake8-3.8.3.tar.gz", hash = "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"}, ] -h11 = [ - {file = "h11-0.10.0-py2.py3-none-any.whl", hash = "sha256:9eecfbafc980976dbff26a01dd3487644dd5d00f8038584451fc64a660f7c502"}, - {file = "h11-0.10.0.tar.gz", hash = "sha256:311dc5478c2568cc07262e0381cdfc5b9c6ba19775905736c87e81ae6662b9fd"}, -] -h2 = [ - {file = "h2-3.2.0-py2.py3-none-any.whl", hash = "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5"}, - {file = "h2-3.2.0.tar.gz", hash = "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"}, -] -hpack = [ - {file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"}, - {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"}, -] -hypercorn = [ - {file = "Hypercorn-0.10.2-py3-none-any.whl", hash = "sha256:809d77f3bf9fa0794a598d8dfa0f8d889e7e1c2f927581cd33068803169dc474"}, - {file = "Hypercorn-0.10.2.tar.gz", hash = "sha256:19f32e7267225c8108ad585b2c5deddf1fe75950797a0e87a682a3a00ef1af95"}, -] -hyperframe = [ - {file = "hyperframe-5.2.0-py2.py3-none-any.whl", hash = "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40"}, - {file = "hyperframe-5.2.0.tar.gz", hash = "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"}, +flask = [ + {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, + {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, @@ -825,25 +674,6 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] -multidict = [ - {file = "multidict-4.7.6-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000"}, - {file = "multidict-4.7.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a"}, - {file = "multidict-4.7.6-cp35-cp35m-win32.whl", hash = "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5"}, - {file = "multidict-4.7.6-cp35-cp35m-win_amd64.whl", hash = "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3"}, - {file = "multidict-4.7.6-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87"}, - {file = "multidict-4.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2"}, - {file = "multidict-4.7.6-cp36-cp36m-win32.whl", hash = "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7"}, - {file = "multidict-4.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463"}, - {file = "multidict-4.7.6-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"}, - {file = "multidict-4.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255"}, - {file = "multidict-4.7.6-cp37-cp37m-win32.whl", hash = "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507"}, - {file = "multidict-4.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c"}, - {file = "multidict-4.7.6-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b"}, - {file = "multidict-4.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7"}, - {file = "multidict-4.7.6-cp38-cp38-win32.whl", hash = "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d"}, - {file = "multidict-4.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19"}, - {file = "multidict-4.7.6.tar.gz", hash = "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430"}, -] parso = [ {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"}, {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"}, @@ -860,10 +690,6 @@ pickleshare = [ {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] -priority = [ - {file = "priority-1.3.0-py2.py3-none-any.whl", hash = "sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb"}, - {file = "priority-1.3.0.tar.gz", hash = "sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe"}, -] 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"}, @@ -888,10 +714,6 @@ pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] -quart = [ - {file = "Quart-0.11.5-py3-none-any.whl", hash = "sha256:187427d1a2d7fed20dcb825dddbe20fd971efd7ec413639f95d2e28ff59a0cb1"}, - {file = "Quart-0.11.5.tar.gz", hash = "sha256:bd93650fa856dcfbc3890952ab3ca53f7755ab506d453a209db63713eceeceda"}, -] 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"}, @@ -915,6 +737,10 @@ regex = [ {file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"}, {file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"}, ] +requests = [ + {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, + {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, +] rope = [ {file = "rope-0.16.0-py2-none-any.whl", hash = "sha256:ae1fa2fd56f64f4cc9be46493ce54bed0dd12dee03980c61a4393d89d84029ad"}, {file = "rope-0.16.0-py3-none-any.whl", hash = "sha256:52423a7eebb5306a6d63bdc91a7c657db51ac9babfb8341c9a1440831ecf3203"}, @@ -944,25 +770,33 @@ typed-ast = [ {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, + {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, + {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] -typing-extensions = [ - {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, - {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, - {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +urllib3 = [ + {file = "urllib3-1.25.11-py2.py3-none-any.whl", hash = "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"}, + {file = "urllib3-1.25.11.tar.gz", hash = "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, @@ -972,29 +806,6 @@ werkzeug = [ {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"}, ] -wsproto = [ - {file = "wsproto-0.15.0-py2.py3-none-any.whl", hash = "sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"}, - {file = "wsproto-0.15.0.tar.gz", hash = "sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d"}, -] -yarl = [ - {file = "yarl-1.5.1-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb"}, - {file = "yarl-1.5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593"}, - {file = "yarl-1.5.1-cp35-cp35m-win32.whl", hash = "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409"}, - {file = "yarl-1.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317"}, - {file = "yarl-1.5.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511"}, - {file = "yarl-1.5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e"}, - {file = "yarl-1.5.1-cp36-cp36m-win32.whl", hash = "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f"}, - {file = "yarl-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2"}, - {file = "yarl-1.5.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a"}, - {file = "yarl-1.5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8"}, - {file = "yarl-1.5.1-cp37-cp37m-win32.whl", hash = "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8"}, - {file = "yarl-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d"}, - {file = "yarl-1.5.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02"}, - {file = "yarl-1.5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a"}, - {file = "yarl-1.5.1-cp38-cp38-win32.whl", hash = "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"}, - {file = "yarl-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692"}, - {file = "yarl-1.5.1.tar.gz", hash = "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6"}, -] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, diff --git a/pyproject.toml b/pyproject.toml index e6faacd..cf0fc32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,11 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.7" -quart = "^0.11.3" -aiohttp = "^3.6.2" structlog = "^20.1.0" colorama = "^0.4.3" dice = "^3.1.0" +flask = "^1.1.2" +requests = "^2.24.0" [tool.poetry.dev-dependencies] black = "^19.10b0" From 490b07d5b45dd91c31d5845df7ec60f84afa4d59 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Wed, 4 Nov 2020 13:14:14 +0100 Subject: [PATCH 24/44] Using waitress to serve wsgi --- docker/bin/start-server.sh | 2 +- poetry.lock | 18 +++++++++++++++++- pyproject.toml | 1 + 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docker/bin/start-server.sh b/docker/bin/start-server.sh index d6be1ca..aa5ea13 100755 --- a/docker/bin/start-server.sh +++ b/docker/bin/start-server.sh @@ -1,3 +1,3 @@ #!/bin/sh -xe -hypercorn butterrobot.app -b "0.0.0.0:${APP_PORT}" +waitress-serve --port=${APP_PORT} 'butterrobot.app:app' \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 056df31..48eba86 100644 --- a/poetry.lock +++ b/poetry.lock @@ -505,6 +505,18 @@ 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)"] +[[package]] +category = "main" +description = "Waitress WSGI server" +name = "waitress" +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" @@ -540,7 +552,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "76fdbeb7b0f4a02bb2ee756204e4579c589cea248bb9b01ddebae4c64ae5d5a3" +content-hash = "2d9ef41b299ed10e058dfec57631714e9c932e2e3826194e341d92edd2371abb" lock-version = "1.0" python-versions = "^3.7" @@ -798,6 +810,10 @@ urllib3 = [ {file = "urllib3-1.25.11-py2.py3-none-any.whl", hash = "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"}, {file = "urllib3-1.25.11.tar.gz", hash = "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2"}, ] +waitress = [ + {file = "waitress-1.4.4-py2.py3-none-any.whl", hash = "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"}, + {file = "waitress-1.4.4.tar.gz", hash = "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261"}, +] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, diff --git a/pyproject.toml b/pyproject.toml index cf0fc32..3fd04a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ colorama = "^0.4.3" dice = "^3.1.0" flask = "^1.1.2" requests = "^2.24.0" +waitress = "^1.4.4" [tool.poetry.dev-dependencies] black = "^19.10b0" From 92f4696a15bd876cbe2a5f9b3d9bad94a1997032 Mon Sep 17 00:00:00 2001 From: Felipe M Date: Wed, 4 Nov 2020 13:25:31 +0100 Subject: [PATCH 25/44] contrib.fun.coin --- butterrobot_plugins_contrib/fun.py | 13 ++++++++++++- pyproject.toml | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py index 8aa0de6..33b69f6 100644 --- a/butterrobot_plugins_contrib/fun.py +++ b/butterrobot_plugins_contrib/fun.py @@ -1,3 +1,5 @@ +import random + import dice from butterrobot.plugins import Plugin @@ -20,4 +22,13 @@ class DicePlugin(Plugin): def on_message(cls, message: Message): if message.text.startswith("!dice"): roll = int(dice.roll(message.text.replace("!dice ", ""))) - yield Message(chat=message.chat, reply_to=message.id, text=roll) \ No newline at end of file + yield Message(chat=message.chat, reply_to=message.id, text=roll) + + +class CoinPlugin(Plugin): + id = "contrib/fun/coin" + + @classmethod + def on_message(cls, message: Message): + if message.text.startswith("!coin"): + yield Message(chat=message.chat, reply_to=message.id, text=random.choice(("heads", "tails"))) diff --git a/pyproject.toml b/pyproject.toml index 3fd04a0..ca320ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,9 @@ ipdb = "^0.13.2" [tool.poetry.plugins] [tool.poetry.plugins."butterrobot.plugins"] "fun.loquito" = "butterrobot_plugins_contrib.fun:LoquitoPlugin" -"dev.ping" = "butterrobot_plugins_contrib.dev:PingPlugin" "fun.dice" = "butterrobot_plugins_contrib.fun:DicePlugin" +"fun.coin" = "butterrobot_plugins_contrib.fun:CoinPlugin" +"dev.ping" = "butterrobot_plugins_contrib.dev:PingPlugin" [build-system] requires = ["poetry>=0.12"] From 456d144a7ddd1a8371d1fe6948c4a465e3ed218f Mon Sep 17 00:00:00 2001 From: Felipe M Date: Wed, 4 Nov 2020 13:25:39 +0100 Subject: [PATCH 26/44] Ignore tls-verification for internal registry --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4386b09..729de14 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ podman@tag-dev: podman tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev podman@push-dev: - podman push registry.int.fmartingr.network/fmartingr/butterrobot:dev + podman push registry.int.fmartingr.network/fmartingr/butterrobot:dev --tls-verify=false podman@dev: make podman@build-dev From 57b413dd1b446e149ce151fc29bd174b11f9554b Mon Sep 17 00:00:00 2001 From: Felipe Martin Garcia Date: Sat, 5 Feb 2022 13:00:20 +0100 Subject: [PATCH 27/44] Added admin interface to manage channels and enabled plugins (#9) * Added base admin login/logout flows * Ignore local database * Channel model * Admin interface for channels and plugins * Added database tests along with workflows * Added some docstrings * Ignore .coverage file * Creating plugins docs WIP * Documentation * Black everything * Some documentation * Coverage for the plugins package as well * DB Fixes * Absolute FROM in Dockerfile * Database and logging fixes * Slack: Support private channels * Added pre-commit * black'd * Fixed UserQuery.create * Fixed ChannelPluginQuery.create exists call * Added ChannelPlugin menu for debugging * Ignore sqlite databases * Updated contributing docs --- .env-example | 2 +- .github/workflows/black.yaml | 27 + .github/workflows/pytest.yaml | 32 + .gitignore | 6 +- .pre-commit-config.yaml | 22 + Dockerfile.dev | 12 +- Makefile | 3 + README.md | 34 +- butterrobot/admin/__init__.py | 0 butterrobot/admin/blueprint.py | 156 +++ butterrobot/admin/templates/_base.j2 | 122 ++ butterrobot/admin/templates/channel_detail.j2 | 140 +++ butterrobot/admin/templates/channel_list.j2 | 45 + .../admin/templates/channel_plugins_list.j2 | 41 + butterrobot/admin/templates/index.j2 | 5 + butterrobot/admin/templates/login.j2 | 32 + butterrobot/admin/templates/plugin_list.j2 | 33 + butterrobot/app.py | 70 +- butterrobot/config.py | 8 +- butterrobot/db.py | 163 +++ butterrobot/http.py | 15 + butterrobot/lib/slack.py | 33 +- butterrobot/lib/telegram.py | 4 +- butterrobot/logging.py | 4 +- butterrobot/objects.py | 48 +- butterrobot/platforms/__init__.py | 26 +- butterrobot/platforms/base.py | 44 +- butterrobot/platforms/debug.py | 9 +- butterrobot/platforms/slack.py | 44 +- butterrobot/platforms/telegram.py | 43 +- butterrobot/plugins.py | 37 +- butterrobot/queue.py | 64 + butterrobot_plugins_contrib/dev.py | 5 +- butterrobot_plugins_contrib/fun.py | 35 +- docker/Dockerfile | 7 +- docs/README.md | 8 + docs/contributing.md | 23 + docs/creating-a-plugin.md | 37 + docs/platforms.md | 8 + docs/plugins.md | 11 + poetry.lock | 1038 ++++++++++++----- pyproject.toml | 6 +- setup.cfg | 2 +- tests/test_db.py | 109 ++ tests/test_objects.py | 18 + 45 files changed, 2210 insertions(+), 421 deletions(-) create mode 100644 .github/workflows/black.yaml create mode 100644 .github/workflows/pytest.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 butterrobot/admin/__init__.py create mode 100644 butterrobot/admin/blueprint.py create mode 100644 butterrobot/admin/templates/_base.j2 create mode 100644 butterrobot/admin/templates/channel_detail.j2 create mode 100644 butterrobot/admin/templates/channel_list.j2 create mode 100644 butterrobot/admin/templates/channel_plugins_list.j2 create mode 100644 butterrobot/admin/templates/index.j2 create mode 100644 butterrobot/admin/templates/login.j2 create mode 100644 butterrobot/admin/templates/plugin_list.j2 create mode 100644 butterrobot/db.py create mode 100644 butterrobot/http.py create mode 100644 butterrobot/queue.py create mode 100644 docs/README.md create mode 100644 docs/contributing.md create mode 100644 docs/creating-a-plugin.md create mode 100644 docs/platforms.md create mode 100644 docs/plugins.md create mode 100644 tests/test_db.py create mode 100644 tests/test_objects.py 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") From 7a3b4434e1e39283e11930f5ba0c64d7536c3f7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Dec 2022 09:16:33 +0100 Subject: [PATCH 28/44] Bump certifi from 2020.12.5 to 2022.12.7 (#18) Bumps [certifi](https://github.com/certifi/python-certifi) from 2020.12.5 to 2022.12.7. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/compare/2020.12.05...2022.12.07) --- updated-dependencies: - dependency-name: certifi dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 97 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0d962d0..a96d3c5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -45,10 +45,10 @@ 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"] +dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "pre-commit", "pympler", "pytest (>=4.3.0)", "six", "sphinx", "zope.interface"] 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"] +tests-no-zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] [[package]] name = "backcall" @@ -88,11 +88,11 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "certifi" -version = "2020.12.5" +version = "2022.12.7" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "cfgv" @@ -151,7 +151,7 @@ banal = ">=1.0.1" sqlalchemy = ">=1.3.2" [package.extras] -dev = ["pip", "nose", "wheel", "flake8", "coverage", "psycopg2-binary", "pymysql", "cryptography"] +dev = ["PyMySQL", "coverage", "cryptography", "flake8", "nose", "pip", "psycopg2-binary", "wheel"] [[package]] name = "decorator" @@ -226,8 +226,8 @@ Jinja2 = ">=2.10.1" Werkzeug = ">=0.15" [package.extras] -dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] -docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] +dev = ["coverage", "pallets-sphinx-themes", "pytest", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet", "tox"] +docs = ["pallets-sphinx-themes", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet"] dotenv = ["python-dotenv"] [[package]] @@ -261,8 +261,8 @@ python-versions = ">=3.6" zipp = ">=0.5" [package.extras] -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)"] +docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] [[package]] name = "iniconfig" @@ -282,6 +282,7 @@ python-versions = ">=2.7" [package.dependencies] ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} +setuptools = "*" [[package]] name = "ipython" @@ -301,6 +302,7 @@ 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] @@ -309,10 +311,10 @@ doc = ["Sphinx (>=1.3)"] kernel = ["ipykernel"] nbconvert = ["nbconvert"] nbformat = ["nbformat"] -notebook = ["notebook", "ipywidgets"] +notebook = ["ipywidgets", "notebook"] parallel = ["ipyparallel"] qtconsole = ["qtconsole"] -test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"] +test = ["ipykernel", "nbformat", "nose (>=0.10.1)", "numpy (>=1.14)", "pygments", "requests", "testpath"] [[package]] name = "ipython-genutils" @@ -333,8 +335,8 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] pipfile = ["pipreqs", "requirementslib"] pyproject = ["toml"] -requirements = ["pipreqs", "pip-api"] -xdg_home = ["appdirs (>=1.4.0)"] +requirements = ["pip-api", "pipreqs"] +xdg-home = ["appdirs (>=1.4.0)"] [[package]] name = "itsdangerous" @@ -385,7 +387,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" MarkupSafe = ">=0.9.2" [package.extras] -babel = ["babel"] +babel = ["Babel"] lingua = ["lingua"] [[package]] @@ -571,7 +573,7 @@ py = ">=1.8.2" toml = "*" [package.extras] -checkqa_mypy = ["mypy (==0.780)"] +checkqa-mypy = ["mypy (==0.780)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] @@ -587,7 +589,7 @@ coverage = ">=4.4" pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "pytest-xdist", "six", "virtualenv"] [[package]] name = "python-dateutil" @@ -639,7 +641,7 @@ idna = ">=2.5,<3" urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +security = ["cryptography (>=1.3.4)", "pyOpenSSL (>=0.14)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] @@ -653,6 +655,19 @@ python-versions = "*" [package.extras] dev = ["pytest"] +[[package]] +name = "setuptools" +version = "65.6.3" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "six" version = "1.15.0" @@ -671,14 +686,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] mssql = ["pyodbc"] -mssql_pymssql = ["pymssql"] -mssql_pyodbc = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] mysql = ["mysqlclient"] -oracle = ["cx-oracle"] +oracle = ["cx_oracle"] postgresql = ["psycopg2"] -postgresql_pg8000 = ["pg8000"] -postgresql_psycopg2binary = ["psycopg2-binary"] -postgresql_psycopg2cffi = ["psycopg2cffi"] +postgresql-pg8000 = ["pg8000"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] pymysql = ["pymysql"] [[package]] @@ -693,10 +708,10 @@ python-versions = "*" six = "*" [package.extras] -azure-pipelines = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "pytest-azurepipelines", "python-rapidjson", "pytest-asyncio"] -dev = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "sphinx", "twisted", "pre-commit", "python-rapidjson", "pytest-asyncio"] +azure-pipelines = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "pytest-azurepipelines", "python-rapidjson", "simplejson"] +dev = ["coverage[toml]", "freezegun (>=0.2.8)", "pre-commit", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson", "sphinx", "twisted"] docs = ["sphinx", "twisted"] -tests = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "python-rapidjson", "pytest-asyncio"] +tests = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson"] [[package]] name = "toml" @@ -738,7 +753,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] brotli = ["brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -758,7 +773,7 @@ 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)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "xonsh (>=0.9.16)"] [[package]] name = "waitress" @@ -770,7 +785,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.extras] docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.9)"] -testing = ["pytest", "pytest-cover", "coverage (>=5.0)"] +testing = ["coverage (>=5.0)", "pytest", "pytest-cover"] [[package]] name = "wcwidth" @@ -789,7 +804,7 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] +dev = ["coverage", "pallets-sphinx-themes", "pytest", "pytest-timeout", "sphinx", "sphinx-issues", "tox"] watchdog = ["watchdog"] [[package]] @@ -801,8 +816,8 @@ optional = false python-versions = ">=3.6" [package.extras] -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)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["func-timeout", "jaraco.itertools", "jaraco.test (>=3.2.0)", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] [metadata] lock-version = "1.1" @@ -843,8 +858,8 @@ black = [ {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, ] cfgv = [ {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, @@ -1109,9 +1124,7 @@ python-dateutil = [ 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"}, @@ -1120,18 +1133,26 @@ pyyaml = [ {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-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, {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-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, {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-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, {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-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, {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"}, @@ -1188,6 +1209,10 @@ rope = [ {file = "rope-0.16.0-py3-none-any.whl", hash = "sha256:52423a7eebb5306a6d63bdc91a7c657db51ac9babfb8341c9a1440831ecf3203"}, {file = "rope-0.16.0.tar.gz", hash = "sha256:d2830142c2e046f5fc26a022fe680675b6f48f81c7fc1f03a950706e746e9dfe"}, ] +setuptools = [ + {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"}, + {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, +] six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, From 9c78ea2d48125b9cf7042041aae70c999594419c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Mar 2023 12:22:10 +0100 Subject: [PATCH 29/44] Bump werkzeug from 1.0.1 to 2.2.3 (#19) Bumps [werkzeug](https://github.com/pallets/werkzeug) from 1.0.1 to 2.2.3. - [Release notes](https://github.com/pallets/werkzeug/releases) - [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/werkzeug/compare/1.0.1...2.2.3) --- updated-dependencies: - dependency-name: werkzeug dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 1714 +++++++++++++++++++++++++-------------------------- 1 file changed, 857 insertions(+), 857 deletions(-) diff --git a/poetry.lock b/poetry.lock index a96d3c5..35ed11d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,5 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + [[package]] name = "alembic" version = "1.4.3" @@ -5,6 +7,10 @@ description = "A database migration tool for SQLAlchemy." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "alembic-1.4.3-py2.py3-none-any.whl", hash = "sha256:4e02ed2aa796bd179965041afa092c55b51fb077de19d61835673cc80672c01c"}, + {file = "alembic-1.4.3.tar.gz", hash = "sha256:5334f32314fb2a56d86b4c4dd1ae34b08c03cae4cb888bc699942104d66bc245"}, +] [package.dependencies] Mako = "*" @@ -19,6 +25,10 @@ description = "A small Python module for determining appropriate platform-specif category = "dev" optional = false python-versions = "*" +files = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] [[package]] name = "appnope" @@ -27,6 +37,10 @@ description = "Disable App Nap on macOS >= 10.9" category = "dev" optional = false python-versions = "*" +files = [ + {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"}, + {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"}, +] [[package]] name = "atomicwrites" @@ -35,6 +49,10 @@ description = "Atomic file writes." category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] [[package]] name = "attrs" @@ -43,6 +61,10 @@ description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, +] [package.extras] dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "pre-commit", "pympler", "pytest (>=4.3.0)", "six", "sphinx", "zope.interface"] @@ -57,6 +79,10 @@ description = "Specifications for callback functions passed in to an API" category = "dev" optional = false python-versions = "*" +files = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] [[package]] name = "banal" @@ -65,6 +91,10 @@ description = "Commons of banal micro-functions for Python." category = "main" optional = false python-versions = "*" +files = [ + {file = "banal-1.0.1-py2.py3-none-any.whl", hash = "sha256:6431876cda7b8ff27b1a64fef3a4bdc1f3f54f8dff7beaa93241e01336a0e91d"}, + {file = "banal-1.0.1.tar.gz", hash = "sha256:5541e7c98ea04841f4ff2887bbc3f2dccf982549a99d01c0939aac250fffcf7a"}, +] [[package]] name = "black" @@ -73,6 +103,10 @@ description = "The uncompromising code formatter." category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, + {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, +] [package.dependencies] appdirs = "*" @@ -93,6 +127,10 @@ description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, +] [[package]] name = "cfgv" @@ -101,6 +139,10 @@ description = "Validate configuration and produce human readable error messages. category = "dev" optional = false python-versions = ">=3.6.1" +files = [ + {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, + {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, +] [[package]] name = "chardet" @@ -109,6 +151,10 @@ description = "Universal encoding detector for Python 2 and 3" category = "main" optional = false python-versions = "*" +files = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] [[package]] name = "click" @@ -117,6 +163,10 @@ description = "Composable command line interface toolkit" category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] [[package]] name = "colorama" @@ -125,6 +175,10 @@ description = "Cross-platform colored terminal text." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] [[package]] name = "coverage" @@ -133,751 +187,7 @@ 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 = ["PyMySQL", "coverage", "cryptography", "flake8", "nose", "pip", "psycopg2-binary", "wheel"] - -[[package]] -name = "decorator" -version = "4.4.2" -description = "Decorators for Humans" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*" - -[[package]] -name = "dice" -version = "3.1.1" -description = "A library for parsing and evaluating dice notation" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -docopt = ">=0.6.1" -pyparsing = ">=2.4.1" - -[[package]] -name = "distlib" -version = "0.3.1" -description = "Distribution utilities" -category = "dev" -optional = false -python-versions = "*" - -[[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" -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" - -[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]] -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.*" - -[package.dependencies] -click = ">=5.1" -itsdangerous = ">=0.24" -Jinja2 = ">=2.10.1" -Werkzeug = ">=0.15" - -[package.extras] -dev = ["coverage", "pallets-sphinx-themes", "pytest", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet", "tox"] -docs = ["pallets-sphinx-themes", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet"] -dotenv = ["python-dotenv"] - -[[package]] -name = "identify" -version = "1.5.13" -description = "File identification library for Python" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" - -[package.extras] -license = ["editdistance"] - -[[package]] -name = "idna" -version = "2.10" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -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 = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] - -[[package]] -name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "ipdb" -version = "0.13.4" -description = "IPython-enabled pdb" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.dependencies] -ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} -setuptools = "*" - -[[package]] -name = "ipython" -version = "7.19.0" -description = "IPython: Productive Interactive Computing" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -appnope = {version = "*", markers = "sys_platform == \"darwin\""} -backcall = "*" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -decorator = "*" -jedi = ">=0.10" -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] -all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] -doc = ["Sphinx (>=1.3)"] -kernel = ["ipykernel"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["ipywidgets", "notebook"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["ipykernel", "nbformat", "nose (>=0.10.1)", "numpy (>=1.14)", "pygments", "requests", "testpath"] - -[[package]] -name = "ipython-genutils" -version = "0.2.0" -description = "Vestigial utilities from IPython" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -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.*" - -[package.extras] -pipfile = ["pipreqs", "requirementslib"] -pyproject = ["toml"] -requirements = ["pip-api", "pipreqs"] -xdg-home = ["appdirs (>=1.4.0)"] - -[[package]] -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.*" - -[[package]] -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.*" - -[package.dependencies] -parso = ">=0.7.0,<0.8.0" - -[package.extras] -qa = ["flake8 (==3.7.9)"] -testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] - -[[package]] -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.*" - -[package.dependencies] -MarkupSafe = ">=0.23" - -[package.extras] -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" -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]] -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.*" - -[[package]] -name = "pexpect" -version = "4.8.0" -description = "Pexpect allows easy control of interactive console applications." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -ptyprocess = ">=0.5" - -[[package]] -name = "pickleshare" -version = "0.7.5" -description = "Tiny 'shelve'-like database with concurrency support" -category = "dev" -optional = false -python-versions = "*" - -[[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" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "ptyprocess" -version = "0.6.0" -description = "Run a subprocess in a pseudo terminal" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "py" -version = "1.9.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pycodestyle" -version = "2.6.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pyflakes" -version = "2.2.0" -description = "passive checker of Python programs" -category = "dev" -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" - -[[package]] -name = "pyparsing" -version = "2.4.7" -description = "Python parsing module" -category = "main" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" - -[[package]] -name = "pytest" -version = "6.1.2" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -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]] -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)", "pytest-xdist", "six", "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.*" - -[package.dependencies] -certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" -idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.27" - -[package.extras] -security = ["cryptography (>=1.3.4)", "pyOpenSSL (>=0.14)"] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] - -[[package]] -name = "rope" -version = "0.16.0" -description = "a python refactoring library..." -category = "dev" -optional = false -python-versions = "*" - -[package.extras] -dev = ["pytest"] - -[[package]] -name = "setuptools" -version = "65.6.3" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -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.*" - -[[package]] -name = "sqlalchemy" -version = "1.3.20" -description = "Database Abstraction Library" -category = "main" -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 = "*" - -[package.dependencies] -six = "*" - -[package.extras] -azure-pipelines = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "pytest-azurepipelines", "python-rapidjson", "simplejson"] -dev = ["coverage[toml]", "freezegun (>=0.2.8)", "pre-commit", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson", "sphinx", "twisted"] -docs = ["sphinx", "twisted"] -tests = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" - -[[package]] -name = "traitlets" -version = "5.0.5" -description = "Traitlets Python configuration system" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -ipython-genutils = "*" - -[package.extras] -test = ["pytest"] - -[[package]] -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 = "*" - -[[package]] -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" - -[package.extras] -brotli = ["brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] -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)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "xonsh (>=0.9.16)"] - -[[package]] -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" - -[package.extras] -docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.9)"] -testing = ["coverage (>=5.0)", "pytest", "pytest-cover"] - -[[package]] -name = "wcwidth" -version = "0.2.5" -description = "Measures the displayed width of unicode strings in a terminal" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -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.*" - -[package.extras] -dev = ["coverage", "pallets-sphinx-themes", "pytest", "pytest-timeout", "sphinx", "sphinx-issues", "tox"] -watchdog = ["watchdog"] - -[[package]] -name = "zipp" -version = "3.4.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["func-timeout", "jaraco.itertools", "jaraco.test (>=3.2.0)", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] - -[metadata] -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.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.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-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, -] -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"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, -] -click = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, -] -colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, -] -coverage = [ +files = [ {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"}, @@ -913,220 +223,702 @@ coverage = [ {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, ] -dataset = [ + +[package.extras] +toml = ["toml"] + +[[package]] +name = "dataset" +version = "1.4.1" +description = "Toolkit for Python-based database access." +category = "main" +optional = false +python-versions = "*" +files = [ {file = "dataset-1.4.1-py2.py3-none-any.whl", hash = "sha256:b6b93662e4fb4240d4d68da4156b7b0713cd3d639455d9c2411636ad0ead59de"}, {file = "dataset-1.4.1.tar.gz", hash = "sha256:97902a3d4d62a506c74904fa7c2512a982f12f9892786f2e77b2cdfbd9f72388"}, ] -decorator = [ + +[package.dependencies] +alembic = ">=0.6.2" +banal = ">=1.0.1" +sqlalchemy = ">=1.3.2" + +[package.extras] +dev = ["PyMySQL", "coverage", "cryptography", "flake8", "nose", "pip", "psycopg2-binary", "wheel"] + +[[package]] +name = "decorator" +version = "4.4.2" +description = "Decorators for Humans" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +files = [ {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, ] -dice = [ + +[[package]] +name = "dice" +version = "3.1.1" +description = "A library for parsing and evaluating dice notation" +category = "main" +optional = false +python-versions = "*" +files = [ {file = "dice-3.1.1-py2.py3-none-any.whl", hash = "sha256:43c427532d64baefda5bb5d29a4bba1adad1f4b5cc2b8ec28dfcbb7228765385"}, {file = "dice-3.1.1.tar.gz", hash = "sha256:99d9c3a90c4f0d016911526c1ea5a10394e047cc3ce61eab22fd34c0f4bc9f60"}, ] -distlib = [ + +[package.dependencies] +docopt = ">=0.6.1" +pyparsing = ">=2.4.1" + +[[package]] +name = "distlib" +version = "0.3.1" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, ] -docopt = [ + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "main" +optional = false +python-versions = "*" +files = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] -filelock = [ + +[[package]] +name = "filelock" +version = "3.0.12" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] -flake8 = [ + +[[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" +files = [ {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, ] -flask = [ + +[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]] +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.*" +files = [ {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, ] -identify = [ + +[package.dependencies] +click = ">=5.1" +itsdangerous = ">=0.24" +Jinja2 = ">=2.10.1" +Werkzeug = ">=0.15" + +[package.extras] +dev = ["coverage", "pallets-sphinx-themes", "pytest", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet", "tox"] +docs = ["pallets-sphinx-themes", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet"] +dotenv = ["python-dotenv"] + +[[package]] +name = "identify" +version = "1.5.13" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +files = [ {file = "identify-1.5.13-py2.py3-none-any.whl", hash = "sha256:9dfb63a2e871b807e3ba62f029813552a24b5289504f5b071dea9b041aee9fe4"}, {file = "identify-1.5.13.tar.gz", hash = "sha256:70b638cf4743f33042bebb3b51e25261a0a10e80f978739f17e7fd4837664a66"}, ] -idna = [ + +[package.extras] +license = ["editdistance"] + +[[package]] +name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] -importlib-metadata = [ + +[[package]] +name = "importlib-metadata" +version = "3.1.1" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ {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 = [ + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] -ipdb = [ + +[[package]] +name = "ipdb" +version = "0.13.4" +description = "IPython-enabled pdb" +category = "dev" +optional = false +python-versions = ">=2.7" +files = [ {file = "ipdb-0.13.4.tar.gz", hash = "sha256:c85398b5fb82f82399fc38c44fe3532c0dde1754abee727d8f5cfcc74547b334"}, ] -ipython = [ + +[package.dependencies] +ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} +setuptools = "*" + +[[package]] +name = "ipython" +version = "7.19.0" +description = "IPython: Productive Interactive Computing" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ {file = "ipython-7.19.0-py3-none-any.whl", hash = "sha256:c987e8178ced651532b3b1ff9965925bfd445c279239697052561a9ab806d28f"}, {file = "ipython-7.19.0.tar.gz", hash = "sha256:cbb2ef3d5961d44e6a963b9817d4ea4e1fa2eb589c371a470fed14d8d40cbd6a"}, ] -ipython-genutils = [ + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.10" +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] +all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["ipykernel", "nbformat", "nose (>=0.10.1)", "numpy (>=1.14)", "pygments", "requests", "testpath"] + +[[package]] +name = "ipython-genutils" +version = "0.2.0" +description = "Vestigial utilities from IPython" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, ] -isort = [ + +[[package]] +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.*" +files = [ {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, ] -itsdangerous = [ + +[package.extras] +pipfile = ["pipreqs", "requirementslib"] +pyproject = ["toml"] +requirements = ["pip-api", "pipreqs"] +xdg-home = ["appdirs (>=1.4.0)"] + +[[package]] +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.*" +files = [ {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, ] -jedi = [ + +[[package]] +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.*" +files = [ {file = "jedi-0.17.2-py2.py3-none-any.whl", hash = "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5"}, {file = "jedi-0.17.2.tar.gz", hash = "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20"}, ] -jinja2 = [ + +[package.dependencies] +parso = ">=0.7.0,<0.8.0" + +[package.extras] +qa = ["flake8 (==3.7.9)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] + +[[package]] +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.*" +files = [ {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] -mako = [ + +[package.dependencies] +MarkupSafe = ">=0.23" + +[package.extras] +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" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ {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"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {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"}, + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] + +[[package]] +name = "markupsafe" +version = "2.1.2" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, + {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, ] -mccabe = [ + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] -nodeenv = [ + +[[package]] +name = "nodeenv" +version = "1.5.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, ] -packaging = [ + +[[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.*" +files = [ {file = "packaging-20.7-py2.py3-none-any.whl", hash = "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"}, {file = "packaging-20.7.tar.gz", hash = "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236"}, ] -parso = [ + +[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.*" +files = [ {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"}, {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"}, ] -pathspec = [ + +[package.extras] +testing = ["docopt", "pytest (>=3.0.7)"] + +[[package]] +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.*" +files = [ {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, ] -pexpect = [ + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, ] -pickleshare = [ + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] -pluggy = [ + +[[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.*" +files = [ {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 = [ + +[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" +files = [ {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 = [ + +[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" +optional = false +python-versions = ">=3.6.1" +files = [ {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 = [ + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.6.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"}, {file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"}, ] -py = [ + +[[package]] +name = "py" +version = "1.9.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, ] -pycodestyle = [ + +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, ] -pyflakes = [ + +[[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] -pygments = [ + +[[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" +files = [ {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"}, {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"}, ] -pyparsing = [ + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] -pytest = [ + +[[package]] +name = "pytest" +version = "6.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, ] -pytest-cov = [ + +[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]] +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.*" +files = [ {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 = [ + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "pytest-xdist", "six", "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" +files = [ {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 = [ + +[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 = "*" +files = [ {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-py3-none-any.whl", hash = "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d"}, ] -pyyaml = [ + +[[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.*" +files = [ {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"}, @@ -1157,7 +949,15 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] -regex = [ + +[[package]] +name = "regex" +version = "2020.11.13" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" +files = [ {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"}, @@ -1200,24 +1000,82 @@ regex = [ {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, ] -requests = [ + +[[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.*" +files = [ {file = "requests-2.25.0-py2.py3-none-any.whl", hash = "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"}, {file = "requests-2.25.0.tar.gz", hash = "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8"}, ] -rope = [ + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<4" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +security = ["cryptography (>=1.3.4)", "pyOpenSSL (>=0.14)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] + +[[package]] +name = "rope" +version = "0.16.0" +description = "a python refactoring library..." +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "rope-0.16.0-py2-none-any.whl", hash = "sha256:ae1fa2fd56f64f4cc9be46493ce54bed0dd12dee03980c61a4393d89d84029ad"}, {file = "rope-0.16.0-py3-none-any.whl", hash = "sha256:52423a7eebb5306a6d63bdc91a7c657db51ac9babfb8341c9a1440831ecf3203"}, {file = "rope-0.16.0.tar.gz", hash = "sha256:d2830142c2e046f5fc26a022fe680675b6f48f81c7fc1f03a950706e746e9dfe"}, ] -setuptools = [ + +[package.extras] +dev = ["pytest"] + +[[package]] +name = "setuptools" +version = "65.6.3" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"}, {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, ] -six = [ + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +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.*" +files = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] -sqlalchemy = [ + +[[package]] +name = "sqlalchemy" +version = "1.3.20" +description = "Database Abstraction Library" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ {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"}, @@ -1257,19 +1115,78 @@ sqlalchemy = [ {file = "SQLAlchemy-1.3.20-cp39-cp39-win_amd64.whl", hash = "sha256:d05cef4a164b44ffda58200efcb22355350979e000828479971ebca49b82ddb1"}, {file = "SQLAlchemy-1.3.20.tar.gz", hash = "sha256:d2f25c7f410338d31666d7ddedfa67570900e248b940d186b48461bd4e5569a1"}, ] -structlog = [ + +[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 = "*" +files = [ {file = "structlog-20.1.0-py2.py3-none-any.whl", hash = "sha256:8a672be150547a93d90a7d74229a29e765be05bd156a35cdcc527ebf68e9af92"}, {file = "structlog-20.1.0.tar.gz", hash = "sha256:7a48375db6274ed1d0ae6123c486472aa1d0890b08d314d2b016f3aa7f35990b"}, ] -toml = [ + +[package.dependencies] +six = "*" + +[package.extras] +azure-pipelines = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "pytest-azurepipelines", "python-rapidjson", "simplejson"] +dev = ["coverage[toml]", "freezegun (>=0.2.8)", "pre-commit", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson", "sphinx", "twisted"] +docs = ["sphinx", "twisted"] +tests = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] -traitlets = [ + +[[package]] +name = "traitlets" +version = "5.0.5" +description = "Traitlets Python configuration system" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"}, {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"}, ] -typed-ast = [ + +[package.dependencies] +ipython-genutils = "*" + +[package.extras] +test = ["pytest"] + +[[package]] +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 = "*" +files = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, @@ -1301,27 +1218,110 @@ typed-ast = [ {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] -urllib3 = [ + +[[package]] +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" +files = [ {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, ] -virtualenv = [ + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] +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" +files = [ {file = "virtualenv-20.4.2-py2.py3-none-any.whl", hash = "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"}, {file = "virtualenv-20.4.2.tar.gz", hash = "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d"}, ] -waitress = [ + +[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)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "xonsh (>=0.9.16)"] + +[[package]] +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" +files = [ {file = "waitress-1.4.4-py2.py3-none-any.whl", hash = "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"}, {file = "waitress-1.4.4.tar.gz", hash = "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261"}, ] -wcwidth = [ + +[package.extras] +docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.9)"] +testing = ["coverage (>=5.0)", "pytest", "pytest-cover"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" +files = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] -werkzeug = [ - {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, - {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"}, + +[[package]] +name = "werkzeug" +version = "2.2.3" +description = "The comprehensive WSGI web application library." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612"}, + {file = "Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe"}, ] -zipp = [ + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog"] + +[[package]] +name = "zipp" +version = "3.4.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, ] + +[package.extras] +docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["func-timeout", "jaraco.itertools", "jaraco.test (>=3.2.0)", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.7" +content-hash = "e4014ee68179696ae11e98413b379d11d25c4169a7373a36218c2b085c95cc8f" From 7c684af8c3ffb149db841f2b71c3fd6b374a801e Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Sun, 20 Apr 2025 13:54:22 +0200 Subject: [PATCH 30/44] refactor: python -> go --- .github/workflows/black.yaml | 27 - .github/workflows/docker-build-latest.yaml | 21 - .github/workflows/pytest.yaml | 32 - .github/workflows/release.yaml | 47 - .gitignore | 10 +- .goreleaser.yml | 150 ++ .pre-commit-config.yaml | 22 - .woodpecker/ci.yml | 23 + .woodpecker/release.yml | 16 + Containerfile | 6 + Dockerfile.dev | 26 - Makefile | 113 +- README.md | 74 +- butterrobot/__init__.py | 0 butterrobot/__main__.py | 6 - butterrobot/admin/__init__.py | 0 butterrobot/admin/blueprint.py | 156 -- butterrobot/admin/templates/channel_detail.j2 | 140 -- butterrobot/admin/templates/channel_list.j2 | 45 - .../admin/templates/channel_plugins_list.j2 | 41 - butterrobot/admin/templates/index.j2 | 5 - butterrobot/admin/templates/login.j2 | 32 - butterrobot/admin/templates/plugin_list.j2 | 33 - butterrobot/app.py | 39 - butterrobot/config.py | 31 - butterrobot/db.py | 163 -- butterrobot/http.py | 15 - butterrobot/lib/__init__.py | 0 butterrobot/lib/slack.py | 61 - butterrobot/lib/telegram.py | 58 - butterrobot/logging.py | 25 - butterrobot/objects.py | 61 - butterrobot/platforms/__init__.py | 30 - butterrobot/platforms/base.py | 67 - butterrobot/platforms/debug.py | 44 - butterrobot/platforms/slack.py | 105 -- butterrobot/platforms/telegram.py | 87 -- butterrobot/plugins.py | 67 - butterrobot/queue.py | 64 - butterrobot_plugins_contrib/__init__.py | 0 butterrobot_plugins_contrib/dev.py | 18 - butterrobot_plugins_contrib/fun.py | 51 - cmd/butterrobot/main.go | 33 + docker/Dockerfile | 17 - docker/bin/start-server.sh | 3 - docs/contributing.md | 16 +- docs/creating-a-plugin.md | 77 +- docs/plugins.md | 4 +- go.mod | 22 + go.sum | 53 + internal/admin/admin.go | 563 +++++++ .../admin/templates/_base.html | 45 +- internal/admin/templates/channel_detail.html | 114 ++ internal/admin/templates/channel_list.html | 64 + .../admin/templates/channel_plugins_list.html | 93 ++ internal/admin/templates/index.html | 15 + internal/admin/templates/login.html | 26 + internal/admin/templates/plugin_list.html | 45 + internal/app/app.go | 293 ++++ internal/config/config.go | 59 + internal/db/db.go | 641 ++++++++ internal/model/message.go | 86 ++ internal/model/platform.go | 46 + internal/model/plugin.go | 28 + internal/platform/init.go | 32 + internal/platform/registry.go | 49 + internal/platform/slack/slack.go | 212 +++ internal/platform/telegram/telegram.go | 262 ++++ internal/plugin/fun/coin.go | 50 + internal/plugin/fun/dice.go | 118 ++ internal/plugin/fun/loquito.go | 40 + internal/plugin/ping/ping.go | 40 + internal/plugin/plugin.go | 82 + internal/queue/queue.go | 99 ++ poetry.lock | 1327 ----------------- pyproject.toml | 43 - setup.cfg | 16 - tests/test_db.py | 109 -- tests/test_objects.py | 18 - 79 files changed, 3594 insertions(+), 3257 deletions(-) delete mode 100644 .github/workflows/black.yaml delete mode 100644 .github/workflows/docker-build-latest.yaml delete mode 100644 .github/workflows/pytest.yaml delete mode 100644 .github/workflows/release.yaml create mode 100644 .goreleaser.yml delete mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/ci.yml create mode 100644 .woodpecker/release.yml create mode 100644 Containerfile delete mode 100644 Dockerfile.dev delete mode 100644 butterrobot/__init__.py delete mode 100644 butterrobot/__main__.py delete mode 100644 butterrobot/admin/__init__.py delete mode 100644 butterrobot/admin/blueprint.py delete mode 100644 butterrobot/admin/templates/channel_detail.j2 delete mode 100644 butterrobot/admin/templates/channel_list.j2 delete mode 100644 butterrobot/admin/templates/channel_plugins_list.j2 delete mode 100644 butterrobot/admin/templates/index.j2 delete mode 100644 butterrobot/admin/templates/login.j2 delete mode 100644 butterrobot/admin/templates/plugin_list.j2 delete mode 100644 butterrobot/app.py delete mode 100644 butterrobot/config.py delete mode 100644 butterrobot/db.py delete mode 100644 butterrobot/http.py delete mode 100644 butterrobot/lib/__init__.py delete mode 100644 butterrobot/lib/slack.py delete mode 100644 butterrobot/lib/telegram.py delete mode 100644 butterrobot/logging.py delete mode 100644 butterrobot/objects.py delete mode 100644 butterrobot/platforms/__init__.py delete mode 100644 butterrobot/platforms/base.py delete mode 100644 butterrobot/platforms/debug.py delete mode 100644 butterrobot/platforms/slack.py delete mode 100644 butterrobot/platforms/telegram.py delete mode 100644 butterrobot/plugins.py delete mode 100644 butterrobot/queue.py delete mode 100644 butterrobot_plugins_contrib/__init__.py delete mode 100644 butterrobot_plugins_contrib/dev.py delete mode 100644 butterrobot_plugins_contrib/fun.py create mode 100644 cmd/butterrobot/main.go delete mode 100644 docker/Dockerfile delete mode 100755 docker/bin/start-server.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/admin/admin.go rename butterrobot/admin/templates/_base.j2 => internal/admin/templates/_base.html (79%) create mode 100644 internal/admin/templates/channel_detail.html create mode 100644 internal/admin/templates/channel_list.html create mode 100644 internal/admin/templates/channel_plugins_list.html create mode 100644 internal/admin/templates/index.html create mode 100644 internal/admin/templates/login.html create mode 100644 internal/admin/templates/plugin_list.html create mode 100644 internal/app/app.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/model/message.go create mode 100644 internal/model/platform.go create mode 100644 internal/model/plugin.go create mode 100644 internal/platform/init.go create mode 100644 internal/platform/registry.go create mode 100644 internal/platform/slack/slack.go create mode 100644 internal/platform/telegram/telegram.go create mode 100644 internal/plugin/fun/coin.go create mode 100644 internal/plugin/fun/dice.go create mode 100644 internal/plugin/fun/loquito.go create mode 100644 internal/plugin/ping/ping.go create mode 100644 internal/plugin/plugin.go create mode 100644 internal/queue/queue.go delete mode 100644 poetry.lock delete mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 tests/test_db.py delete mode 100644 tests/test_objects.py diff --git a/.github/workflows/black.yaml b/.github/workflows/black.yaml deleted file mode 100644 index 3d0810e..0000000 --- a/.github/workflows/black.yaml +++ /dev/null @@ -1,27 +0,0 @@ -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/docker-build-latest.yaml b/.github/workflows/docker-build-latest.yaml deleted file mode 100644 index de0b1e9..0000000 --- a/.github/workflows/docker-build-latest.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: Build latest tag docker image - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Build the Docker image - run: docker build --tag butterrobot:$(git rev-parse --short HEAD) -f Dockerfile.dev . - - - name: Push into Github packages (latest) - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u fmartingr --password-stdin - docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest - docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml deleted file mode 100644 index 8db9c98..0000000 --- a/.github/workflows/pytest.yaml +++ /dev/null @@ -1,32 +0,0 @@ -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/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 00984ea..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,47 +0,0 @@ -name: Release - -on: - push: - branches: - - stable - -jobs: - prepare: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - publish: - runs-on: ubuntu-latest - needs: - - prepare - steps: - - name: Set up Python 3.8 - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - - name: Install poetry - run: | - pip install poetry - - - name: Build and publish - run: | - poetry publish -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} --build - - build: - runs-on: ubuntu-latest - needs: - - prepare - - publish - steps: - - name: Build the Docker image - run: docker build --tag butterrobot:$(git rev-parse --short HEAD) docker - - - name: Push into Github packages (stable) - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u fmartingr --password-stdin - docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:stable - docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:$(cat pyproject.toml | grep version | cut -d "\"" -f 2) - docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:stable - docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:$(cat pyproject.toml | grep version | cut -d "\"" -f 2) diff --git a/.gitignore b/.gitignore index 309685e..d964ffb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,16 +4,10 @@ __pycache__ *~ *.cert .env-local -test.py .coverage -# Distribution dist -*.egg-info -pip-wheel-metadata - -# Github Codespaces -pythonenv3.8 - +bin # Butterrobot *.sqlite* +butterrobot.db diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..68d1f62 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,150 @@ +version: 2 + +gitea_urls: + api: https://git.nakama.town/api/v1 + download: https://git.nakama.town + +before: + hooks: + - go mod tidy + +git: + ignore_tags: + - "{{ if not .IsNightly }}*-rc*{{ end }}" + +builds: + - binary: butterrobot + main: ./cmd/butterrobot + env: + - CGO_ENABLED=0 + - GIN_MODE=release + tags: + - netgo + - osusergo + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm + - arm64 + goarm: + - "7" + ignore: + - goos: darwin + goarch: arm + - goos: windows + goarch: arm + - goos: windows + goarch: arm64 + +archives: + - id: butterrobot + name_template: >- + {{ .ProjectName }}_ + {{- if eq .Os "darwin" }}Darwin{{- else if eq .Os "linux" }}Linux{{- else if eq .Os "windows" }}Windows{{- else }}{{ .Os }}{{ end }}_ + {{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "arm64" }}aarch64{{- else }}{{ .Arch }}{{ end }}_{{ .Version }} + format_overrides: + - goos: windows + format: zip + +dockers: +- image_templates: + - &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-amd64" + use: buildx + dockerfile: &dockerfile Containerfile + goos: linux + goarch: amd64 + build_flag_templates: + - "--pull" + - "--platform=linux/amd64" +- image_templates: + - &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-arm64" + use: buildx + dockerfile: *dockerfile + goos: linux + goarch: arm64 + build_flag_templates: + - "--pull" + - "--platform=linux/arm64" +- image_templates: + - &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-armv7" + use: buildx + dockerfile: *dockerfile + goos: linux + goarch: arm + goarm: "7" + build_flag_templates: + - "--pull" + - "--platform=linux/arm/v7" + +docker_manifests: + - name_template: "git.nakama.town/fmartingr/butterrobot:{{ .Version }}" + image_templates: + - *amd64_image + - *arm64_image + - *armv7_image + # - name_template: "git.nakama.town/fmartingr/butterrobot:latest" + # image_templates: + # - *amd64_image + # - *arm64_image + # - *armv7_image + +nfpms: + - maintainer: Felipe Martin + description: SMTP server to forward messages to shoutrrr endpoints + homepage: https://git.nakama.town/fmartingr/butterrobot + license: AGPL-3.0 + formats: + - deb + - rpm + - apk + +upx: + - enabled: true + ids: + - butterrobot + goos: [linux, darwin] + goarch: [amd64, arm, arm64] + goarm: ["7"] + +checksum: + name_template: 'checksums.txt' + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: "Fixes" + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: "Performance" + regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: API + regexp: '^.*?api(\([[:word:]]+\))??!?:.+$' + order: 3 + - title: Documentation + regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' + order: 4 + - title: "Tests" + regexp: '^.*?test(\([[:word:]]+\))??!?:.+$' + order: 5 + - title: CI and Delivery + regexp: '^.*?ci(\([[:word:]]+\))??!?:.+$' + order: 6 + - title: Others + order: 999 + filters: + exclude: + - "^deps:" + - "^chore\\(deps\\):" + +release: + prerelease: auto diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 621aa22..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,22 +0,0 @@ -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/.woodpecker/ci.yml b/.woodpecker/ci.yml new file mode 100644 index 0000000..4353088 --- /dev/null +++ b/.woodpecker/ci.yml @@ -0,0 +1,23 @@ +when: + event: + - push + - pull_request + branch: + - main + +steps: + format: + image: golang:1.24 + commands: + - make format + - git diff --exit-code # Fail if files were changed + + lint: + image: golang:1.24 + commands: + - make ci-lint + + test: + image: golang:1.24 + commands: + - make test diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml new file mode 100644 index 0000000..b24eb15 --- /dev/null +++ b/.woodpecker/release.yml @@ -0,0 +1,16 @@ +when: + - event: tag + branch: main + +steps: + - name: Release + image: goreleaser/goreleaser:latest + environment: + GITEA_TOKEN: + from_secret: GITEA_TOKEN + DOCKER_HOST: unix:///var/run/docker.sock + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + commands: + - docker login -u fmartingr -p $GITEA_TOKEN git.nakama.town + - goreleaser release --clean diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..6a9ff7d --- /dev/null +++ b/Containerfile @@ -0,0 +1,6 @@ +# This file is used directly by the goreleaser build +# It is used to build the final container image +FROM scratch +WORKDIR / +COPY /butterrobot /usr/bin/butterrobot +ENTRYPOINT ["/usr/bin/butterrobot"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index c1a4a16..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,26 +0,0 @@ -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_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} && \ - 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 2791e1c..ff08163 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,100 @@ -# Local development -setup: - poetry install +PROJECT_NAME := butterrobot -podman@build: - podman build -t fmartingr/butterrobot -f docker/Dockerfile docker +SOURCE_FILES ?=./... -podman@build-dev: - podman build -t fmartingr/butterrobot:dev -f Dockerfile.dev . +TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out +TEST_TIMEOUT ?=1m -podman@tag-dev: - podman tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev +GOLANGCI_LINT_VERSION ?= v1.64.5 -podman@push-dev: - podman push registry.int.fmartingr.network/fmartingr/butterrobot:dev --tls-verify=false +CLEAN_OPTIONS ?=-modcache -testcache -podman@dev: - make podman@build-dev - make podman@tag-dev - make podman@push-dev +CGO_ENABLED := 0 -test: - poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib +BUILDS_PATH := ./dist +FROM_MAKEFILE := y -clean: - rm -rf dist - rm -rf butterrobot.egg-info +CONTAINERFILE_NAME := Containerfile +CONTAINER_ALPINE_VERSION := 3.21 +CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}" +CONTAINER_MAINTAINER := "Felipe Martin " +CONTAINER_BIN_NAME := ${PROJECT_NAME} + +BUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/arm/v7 + +export PROJECT_NAME +export FROM_MAKEFILE + +export CGO_ENABLED + +export SOURCE_FILES +export TEST_OPTIONS +export TEST_TIMEOUT +export BUILDS_PATH + +export CONTAINERFILE_NAME +export CONTAINER_ALPINE_VERSION +export CONTAINER_SOURCE_URL +export CONTAINER_MAINTAINER +export CONTAINER_BIN_NAME + +export BUILDX_PLATFORMS + +.PHONY: all +all: help + +# this is godly +# https://news.ycombinator.com/item?id=11939200 +.PHONY: help +help: ### this screen. Keep it first target to be default +ifeq ($(UNAME), Linux) + @grep -P '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' +else + @# this is not tested, but prepared in advance for you, Mac drivers + @awk -F ':.*###' '$$0 ~ FS {printf "%15s%s\n", $$1 ":", $$2}' \ + $(MAKEFILE_LIST) | grep -v '@awk' | sort +endif + +.PHONY: clean +clean: ### clean test cache, build files + $(info: Make: Clean) + @rm -rf ${BUILDS_PATH} + @go clean ${CLEAN_OPTIONS} + @-docker buildx rm ${PROJECT_NAME}_builder + +.PHONY: build +build: clean ### builds the project for the setup os/arch combinations + $(info: Make: Build) + @goreleaser --clean --snapshot + +.PHONY: quick-run +quick-run: ### Executes the project using golang + CGO_ENABLED=${CGO_ENABLED} go run ./cmd/${PROJECT_NAME}/*.go + +.PHONY: run +run: ### Executes the project build locally + @make build + ${BUILDS_PATH}/${PROJECT_NAME} + +.PHONY: format +format: ### Executes the formatting pipeline on the project + $(info: Make: Format) + @go fmt ./... + @go mod tidy + +.PHONY: ci-lint +ci-lint: ### Check the project for errors + $(info: Make: Lint) + @go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} + @golangci-lint run ./... + +.PHONY: lint +lint: ### Check the project for errors + $(info: Make: Lint) + @golangci-lint run ./... + +.PHONY: test +test: ### Runs the test suite + $(info: Make: Test) + CGO_ENABLED=1 go test ${TEST_OPTIONS} -timeout=${TEST_TIMEOUT} ${SOURCE_FILES} diff --git a/README.md b/README.md index 7fb78d6..36ec708 100644 --- a/README.md +++ b/README.md @@ -2,52 +2,78 @@ | 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) | +| ![Build stable tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) | ![Build latest tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) | +| ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=stable) | ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=master) | -Python framework to create bots for several platforms. +Go framework to create bots for several platforms. ![Butter Robot](./assets/icon@120.png) > What is my purpose? +## Features + +- Support for multiple chat platforms (Slack, Telegram) +- Plugin system for easy extension +- Admin interface for managing channels and plugins +- Message queue for asynchronous processing + ## Documentation [Go to documentation](./docs) ## Installation -### PyPi +### From Source -You can run it directly by installing the package and calling it -with `python` though this is not recommended and only intended for -development purposes. +```bash +# Clone the repository +git clone https://git.nakama.town/fmartingr/butterrobot.git +cd butterrobot -``` -$ pip install --user butterrobot -$ python -m butterrobot +# Build the application +go build -o butterrobot ./cmd/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: +```bash +docker pull docker.pkg.git.nakama.town/fmartingr/butterrobot/butterrobot:latest +docker run -d --name butterrobot -p 8080:8080 docker.pkg.git.nakama.town/fmartingr/butterrobot/butterrobot:latest ``` -docker pull docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest -podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080 -``` + +## Configuration + +Configuration is done through environment variables: + +- `DEBUG`: Set to "y" to enable debug mode +- `BUTTERROBOT_HOSTNAME`: Hostname for webhook URLs +- `LOG_LEVEL`: Logging level (DEBUG, INFO, WARN, ERROR) +- `SECRET_KEY`: Secret key for sessions and password hashing +- `DATABASE_PATH`: Path to SQLite database file + +### Platform-specific configuration + +#### Slack + +- `SLACK_TOKEN`: Slack app access token +- `SLACK_BOT_OAUTH_ACCESS_TOKEN`: Slack bot OAuth access token + +#### Telegram + +- `TELEGRAM_TOKEN`: Telegram bot token ## Contributing -To run the project locally you will need [poetry](https://python-poetry.org/). - -``` +```bash git clone git@github.com:fmartingr/butterrobot.git cd butterrobot -poetry install +go mod download ``` -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: ``` SLACK_TOKEN=xxx @@ -55,8 +81,12 @@ TELEGRAM_TOKEN=xxx ... ``` -And then you can run it directly with poetry +And then you can run it directly: +```bash +go run ./cmd/butterrobot/main.go ``` -docker run -it --rm --env-file .env-local -p 5000:5000 -v $PWD/butterrobot:/etc/app/butterrobot local/butterrobot python -m butterrobot -``` + +## License + +GPL-2.0 diff --git a/butterrobot/__init__.py b/butterrobot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot/__main__.py b/butterrobot/__main__.py deleted file mode 100644 index 2f0182a..0000000 --- a/butterrobot/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -from butterrobot.app import app -from butterrobot.config import DEBUG - -# Only used for local development! -# python -m butterrobot -app.run(debug=DEBUG, host="0.0.0.0") diff --git a/butterrobot/admin/__init__.py b/butterrobot/admin/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot/admin/blueprint.py b/butterrobot/admin/blueprint.py deleted file mode 100644 index b9442fd..0000000 --- a/butterrobot/admin/blueprint.py +++ /dev/null @@ -1,156 +0,0 @@ -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/channel_detail.j2 b/butterrobot/admin/templates/channel_detail.j2 deleted file mode 100644 index 409a230..0000000 --- a/butterrobot/admin/templates/channel_detail.j2 +++ /dev/null @@ -1,140 +0,0 @@ -{% 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 deleted file mode 100644 index 6bb0887..0000000 --- a/butterrobot/admin/templates/channel_list.j2 +++ /dev/null @@ -1,45 +0,0 @@ -{% 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 deleted file mode 100644 index 2ee69bb..0000000 --- a/butterrobot/admin/templates/channel_plugins_list.j2 +++ /dev/null @@ -1,41 +0,0 @@ -{% 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 deleted file mode 100644 index 70b55ea..0000000 --- a/butterrobot/admin/templates/index.j2 +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} - -{% endblock %} diff --git a/butterrobot/admin/templates/login.j2 b/butterrobot/admin/templates/login.j2 deleted file mode 100644 index eb78117..0000000 --- a/butterrobot/admin/templates/login.j2 +++ /dev/null @@ -1,32 +0,0 @@ -{% 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 deleted file mode 100644 index 68532fb..0000000 --- a/butterrobot/admin/templates/plugin_list.j2 +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} - - -
-
- - - - - - - - - {% for plugin in plugins %} - - - - {% endfor %} - -
Name
{{ plugin.name }}
-
-
-{% endblock %} diff --git a/butterrobot/app.py b/butterrobot/app.py deleted file mode 100644 index 22344c0..0000000 --- a/butterrobot/app.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -import structlog -from flask import Flask, request - -import butterrobot.logging # noqa -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__) -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 get_available_platforms(): - return {"error": "Unknown platform"}, 400 - - q.put( - { - "platform": platform, - "request": {"path": request.path, "json": request.get_json()}, - } - ) - - return {} - - -@app.route("/healthz") -def healthz(): - return {} diff --git a/butterrobot/config.py b/butterrobot/config.py deleted file mode 100644 index cefa366..0000000 --- a/butterrobot/config.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -# --- Butter Robot ----------------------------------------------------------------- -DEBUG = os.environ.get("DEBUG", "n") == "y" - -HOSTNAME = os.environ.get( - "BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network" -) - -LOG_LEVEL = os.environ.get("LOG_LEVEL", "ERROR") - -SECRET_KEY = os.environ.get("SECRET_KEY", "1234") - -# --- DATABASE --------------------------------------------------------------------- -DATABASE_PATH = os.environ.get("DATABASE_PATH", "sqlite:///butterrobot.sqlite") - -# --- PLATFORMS --------------------------------------------------------------------- -# --- -# Slack -# --- -# Slack app access token -SLACK_TOKEN = os.environ.get("SLACK_TOKEN") - -# Slack app oauth access token to send messages on the bot behalf -SLACK_BOT_OAUTH_ACCESS_TOKEN = os.environ.get("SLACK_BOT_OAUTH_ACCESS_TOKEN") - -# --- -# Telegram -# --- -# Telegram auth token -TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN") diff --git a/butterrobot/db.py b/butterrobot/db.py deleted file mode 100644 index fe29920..0000000 --- a/butterrobot/db.py +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index 9086155..0000000 --- a/butterrobot/http.py +++ /dev/null @@ -1,15 +0,0 @@ -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/__init__.py b/butterrobot/lib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot/lib/slack.py b/butterrobot/lib/slack.py deleted file mode 100644 index b8cc7d3..0000000 --- a/butterrobot/lib/slack.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Optional, Text - -import requests -import structlog - -from butterrobot.config import SLACK_BOT_OAUTH_ACCESS_TOKEN - - -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 - - 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 = { - "text": message, - "channel": channel, - } - - if thread: - payload["thread_ts"] = thread - - 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 deleted file mode 100644 index a10ecfa..0000000 --- a/butterrobot/lib/telegram.py +++ /dev/null @@ -1,58 +0,0 @@ -import requests -import structlog - -from butterrobot.config import TELEGRAM_TOKEN - - -logger = structlog.get_logger(__name__) - - -class TelegramAPI: - BASE_URL = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}" - - DEFAULT_ALLOWED_UPDATES = ["message"] - - class TelegramError(Exception): - pass - - class TelegramClientError(Exception): - pass - - @classmethod - def set_webhook(cls, webhook_url, max_connections=40, allowed_updates=None): - allowed_updates = allowed_updates or cls.DEFAULT_ALLOWED_UPDATES - url = f"{cls.BASE_URL}/setWebhook" - payload = { - "url": webhook_url, - "max_connections": max_connections, - "allowed_updates": allowed_updates, - } - response = requests.post(url, json=payload) - response_json = response.json() - if not response_json["ok"]: - raise cls.TelegramClientError(response_json) - - @classmethod - def send_message( - cls, - chat_id, - text, - parse_mode="markdown", - disable_web_page_preview=False, - disable_notification=False, - reply_to_message_id=None, - ): - url = f"{cls.BASE_URL}/sendMessage" - payload = { - "chat_id": chat_id, - "text": text, - "parse_mode": parse_mode, - "disable_web_page_preview": disable_web_page_preview, - "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) diff --git a/butterrobot/logging.py b/butterrobot/logging.py deleted file mode 100644 index f31e5b8..0000000 --- a/butterrobot/logging.py +++ /dev/null @@ -1,25 +0,0 @@ -import logging - -import structlog - -from butterrobot.config import LOG_LEVEL, DEBUG - - -logging.basicConfig(format="%(message)s", level=LOG_LEVEL) -structlog.configure( - processors=[ - structlog.stdlib.add_log_level, - structlog.stdlib.add_logger_name, - structlog.dev.set_exc_info, - 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(), - ], - context_class=dict, - logger_factory=structlog.stdlib.LoggerFactory(), - wrapper_class=structlog.BoundLogger, - cache_logger_on_first_use=True, -) diff --git a/butterrobot/objects.py b/butterrobot/objects.py deleted file mode 100644 index 215924b..0000000 --- a/butterrobot/objects.py +++ /dev/null @@ -1,61 +0,0 @@ -from datetime import datetime -from dataclasses import dataclass, field -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 deleted file mode 100644 index dad1360..0000000 --- a/butterrobot/platforms/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -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 - - -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 deleted file mode 100644 index a5d778e..0000000 --- a/butterrobot/platforms/base.py +++ /dev/null @@ -1,67 +0,0 @@ -from abc import abstractmethod -from dataclasses import dataclass - - -class Platform: - class PlatformError(Exception): - pass - - class PlatformInitError(PlatformError): - pass - - class PlatformAuthError(PlatformError): - pass - - @dataclass - class PlatformAuthResponse(PlatformError): - """ - 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: - @classmethod - @abstractmethod - def send_message(cls, message): - """Method used to send a message via the platform""" - pass diff --git a/butterrobot/platforms/debug.py b/butterrobot/platforms/debug.py deleted file mode 100644 index 9982011..0000000 --- a/butterrobot/platforms/debug.py +++ /dev/null @@ -1,44 +0,0 @@ -import uuid -from datetime import datetime - -import structlog - -from butterrobot.platforms.base import Platform, PlatformMethods -from butterrobot.objects import Message, Channel - - -logger = structlog.get_logger(__name__) - - -class DebugMethods(PlatformMethods): - @classmethod - def send_message(self, message: Message): - logger.debug( - "Outgoing message", message=message.__dict__, platform=DebugPlatform.ID - ) - - -class DebugPlatform(Platform): - ID = "debug" - - methods = DebugMethods - - @classmethod - def parse_incoming_message(cls, request): - request_data = request["json"] - logger.debug("Parsing message", data=request_data, platform=cls.ID) - - return Message( - id=str(uuid.uuid4()), - date=datetime.now(), - text=request_data["text"], - 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 deleted file mode 100644 index aa1b133..0000000 --- a/butterrobot/platforms/slack.py +++ /dev/null @@ -1,105 +0,0 @@ -from datetime import datetime - -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, Channel -from butterrobot.lib.slack import SlackAPI - - -logger = structlog.get_logger(__name__) - - -class SlackMethods(PlatformMethods): - @classmethod - def send_message(self, message: Message): - logger.debug( - "Outgoing message", message=message.__dict__, platform=SlackPlatform.ID - ) - try: - SlackAPI.send_message( - channel=message.chat, message=message.text, thread=message.reply_to - ) - except SlackAPI.SlackClientError as error: - logger.error( - "Send message error", - platform=SlackPlatform.ID, - error=error, - message=message.__dict__, - ) - - -class SlackPlatform(Platform): - ID = "slack" - - methods = SlackMethods - - @classmethod - def init(cls, app): - if not (SLACK_TOKEN and SLACK_BOT_OAUTH_ACCESS_TOKEN): - 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["json"] - - # Auth - if data.get("token") != SLACK_TOKEN: - raise cls.PlatformAuthError("Authentication error") - - # Confirms challenge request to configure webhook - if "challenge" in data: - raise cls.PlatformAuthResponse(data={"challenge": data["challenge"]}) - - # Discard messages by webhooks and apps - if "bot_id" in data["event"]: - logger.debug("Discarding message", data=data) - return - - logger.debug("Parsing message", platform=cls.ID, data=data) - - if data["event"]["type"] not in ("message", "message.groups"): - return - - # 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"].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 deleted file mode 100644 index ddf7607..0000000 --- a/butterrobot/platforms/telegram.py +++ /dev/null @@ -1,87 +0,0 @@ -from datetime import datetime - -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, Channel - - -logger = structlog.get_logger(__name__) - - -class TelegramMethods(PlatformMethods): - @classmethod - def send_message(self, message: Message): - logger.debug( - "Outgoing message", message=message.__dict__, platform=TelegramPlatform.ID - ) - TelegramAPI.send_message( - chat_id=message.chat, - text=message.text, - reply_to_message_id=message.reply_to, - ) - - -class TelegramPlatform(Platform): - ID = "telegram" - - methods = TelegramMethods - - @classmethod - def init(cls, app): - """ - Initializes the Telegram webhook endpoint to receive updates - """ - - if not TELEGRAM_TOKEN: - logger.error("Missing token. platform not enabled.", platform=cls.ID) - return - - webhook_url = f"https://{HOSTNAME}/telegram/incoming/{TELEGRAM_TOKEN}" - try: - TelegramAPI.set_webhook(webhook_url) - except TelegramAPI.TelegramError as error: - 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] - if token != TELEGRAM_TOKEN: - raise cls.PlatformAuthError("Authentication error") - - logger.debug("Parsing message", data=request["json"], platform=cls.ID) - - if "text" in request["json"]["message"]: - # Ignore all messages but text messages - return Message( - 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 deleted file mode 100644 index 0ee3b55..0000000 --- a/butterrobot/plugins.py +++ /dev/null @@ -1,67 +0,0 @@ -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, 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 auto discovered plugin - """ - plugins = {} - for ep in pkg_resources.iter_entry_points("butterrobot.plugins"): - try: - plugin_cls = ep.load() - plugins[plugin_cls.id] = plugin_cls - except Exception as error: - logger.error( - "Error loading plugin", - exception=str(error), - traceback=traceback.format_exc(), - plugin=ep.name, - project_name=ep.dist.project_name, - entry_point=ep, - module=ep.module_name, - ) - - return plugins diff --git a/butterrobot/queue.py b/butterrobot/queue.py deleted file mode 100644 index 82f99a7..0000000 --- a/butterrobot/queue.py +++ /dev/null @@ -1,64 +0,0 @@ -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/__init__.py b/butterrobot_plugins_contrib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot_plugins_contrib/dev.py b/butterrobot_plugins_contrib/dev.py deleted file mode 100644 index c03f6c7..0000000 --- a/butterrobot_plugins_contrib/dev.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime - -from butterrobot.plugins import Plugin -from butterrobot.objects import Message - - -class PingPlugin(Plugin): - name = "Ping command" - id = "contrib.dev.ping" - - @classmethod - def on_message(cls, message, **kwargs): - if message.text == "!ping": - delta = datetime.now() - message.date - delta_ms = delta.seconds * 1000 + delta.microseconds / 1000 - yield Message( - chat=message.chat, reply_to=message.id, text=f"pong! ({delta_ms}ms)", - ) diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py deleted file mode 100644 index 4a5b3bf..0000000 --- a/butterrobot_plugins_contrib/fun.py +++ /dev/null @@ -1,51 +0,0 @@ -import random - -import dice -import structlog - -from butterrobot.plugins import Plugin -from butterrobot.objects import Message - - -logger = structlog.get_logger(__name__) - - -class LoquitoPlugin(Plugin): - name = "Loquito reply" - id = "contrib.fun.loquito" - - @classmethod - 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.", - ) - - -class DicePlugin(Plugin): - name = "Dice command" - id = "contrib.fun.dice" - DEFAULT_FORMULA = "1d20" - - @classmethod - def on_message(cls, message: Message, **kwargs): - if message.text.startswith("!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): - name = "Coin command" - id = "contrib.fun.coin" - - @classmethod - 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")), - ) diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go new file mode 100644 index 0000000..5cf57f9 --- /dev/null +++ b/cmd/butterrobot/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "log/slog" + "os" + + "git.nakama.town/fmartingr/butterrobot/internal/app" + "git.nakama.town/fmartingr/butterrobot/internal/config" +) + +func main() { + // Initialize logger + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + // Load configuration + cfg, err := config.Load() + if err != nil { + logger.Error("Failed to load configuration", "error", err) + os.Exit(1) + } + + // Initialize and run application + application, err := app.New(cfg, logger) + if err != nil { + logger.Error("Failed to initialize application", "error", err) + os.Exit(1) + } + + if err := application.Run(); err != nil { + logger.Error("Application error", "error", err) + os.Exit(1) + } +} \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 7c95591..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM alpine:3.11 - -ENV PYTHON_VERSION=3.8.2-r1 -ENV APP_PORT 8080 -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} && \ - mkdir ${APP_PATH} && \ - chown -R 1000:1000 ${APP_PATH} - -USER 1000 - -CMD ["/usr/local/bin/start-server"] diff --git a/docker/bin/start-server.sh b/docker/bin/start-server.sh deleted file mode 100755 index aa5ea13..0000000 --- a/docker/bin/start-server.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -xe - -waitress-serve --port=${APP_PORT} 'butterrobot.app:app' \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md index ae11cef..8c7ff76 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,11 +1,12 @@ ## Contributing -To run the project locally you will need [poetry](https://python-poetry.org/). +To run the project locally you will need Go 1.19 or higher. -``` +```bash git clone git@github.com:fmartingr/butterrobot.git cd butterrobot make setup +make build ``` Create a `.env-local` file with the required environment variables, you have [an example file](.env-example). @@ -13,11 +14,16 @@ Create a `.env-local` file with the required environment variables, you have [an ``` SLACK_TOKEN=xxx TELEGRAM_TOKEN=xxx +HOSTNAME=myhostname.com ... ``` -And then you can run it directly with poetry: +And then you can run it directly: -``` -poetry run python -m butterrobot +```bash +# Run directly with Go +go run ./cmd/butterrobot/main.go + +# Or run the built binary +./bin/butterrobot ``` diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md index 0f4cee6..945d03c 100644 --- a/docs/creating-a-plugin.md +++ b/docs/creating-a-plugin.md @@ -2,36 +2,61 @@ ## Example -This simple "Marco Polo" plugin will answer _Polo_ to the user that say _Marco_: +This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_: -``` python -# mypackage/plugins.py -from butterrobot.plugins import Plugin -from butterrobot.objects import Message +```go +package myplugin +import ( + "strings" -class PingPlugin(Plugin): - name = "Marco/Polo" - id = "test.marco" + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) - @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" +// MarcoPlugin is a simple Marco/Polo plugin +type MarcoPlugin struct { + plugin.BasePlugin } -setup( - # ... - entry_points=entrypoints, - # ... -) +// New creates a new MarcoPlugin instance +func New() *MarcoPlugin { + return &MarcoPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "test.marco", + Name: "Marco/Polo", + Help: "Responds to 'Marco' with 'Polo'", + }, + } +} + +// OnMessage handles incoming messages +func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + if !strings.EqualFold(strings.TrimSpace(msg.Text), "Marco") { + return nil + } + + response := &model.Message{ + Text: "Polo", + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} +``` + +To use the plugin, register it in your application: + +```go +// In app.go or similar initialization file +func (a *App) Run() error { + // ... + + // Register plugins + plugin.Register(myplugin.New()) + + // ... +} ``` diff --git a/docs/plugins.md b/docs/plugins.md index e4fcd29..11e3d16 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,10 +2,10 @@ ### Development -- `!ping`: Say `!ping` to get response with time elapsed. +- `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. +- Coin: Flip a coin and get heads or tails. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ab85fc8 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module git.nakama.town/fmartingr/butterrobot + +go 1.24 + +require ( + github.com/gorilla/sessions v1.4.0 + modernc.org/sqlite v1.37.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.32.0 // indirect + modernc.org/libc v1.63.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.10.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..248cd40 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= +modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= +modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA= +modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/admin/admin.go b/internal/admin/admin.go new file mode 100644 index 0000000..715c5e1 --- /dev/null +++ b/internal/admin/admin.go @@ -0,0 +1,563 @@ +package admin + +import ( + "html/template" + "net/http" + "path/filepath" + "strconv" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/config" + "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" + "github.com/gorilla/sessions" +) + +const ( + // Session store key + sessionKey = "butterrobot-session" + + // Template directory + templateDir = "./internal/admin/templates" +) + +// FlashMessage represents a flash message +type FlashMessage struct { + Category string + Message string +} + +// TemplateData holds data for rendering templates +type TemplateData struct { + User *model.User + LoggedIn bool + Title string + Path string + Flash []FlashMessage + Plugins map[string]model.Plugin + Channels []*model.Channel + Channel *model.Channel + ChannelPlugin *model.ChannelPlugin +} + +// Admin represents the admin interface +type Admin struct { + config *config.Config + db *db.Database + store *sessions.CookieStore + templates map[string]*template.Template + baseTemplate *template.Template +} + +// New creates a new Admin instance +func New(cfg *config.Config, database *db.Database) *Admin { + // Create session store + store := sessions.NewCookieStore([]byte(cfg.SecretKey)) + + // Load templates + templates := make(map[string]*template.Template) + + // Create a template function map with helper functions + funcMap := template.FuncMap{ + "contains": strings.Contains, + } + + // Create a custom template with functions + baseTemplate := template.New("_base.html").Funcs(funcMap) + baseTemplate = template.Must(baseTemplate.ParseFiles(filepath.Join(templateDir, "_base.html"))) + + // Parse and register all templates + templateFiles := []string{ + "index.html", + "login.html", + "channel_list.html", + "channel_detail.html", + "plugin_list.html", + "channel_plugins_list.html", + } + + for _, tf := range templateFiles { + // Create a clone of the base template + t, err := template.Must(baseTemplate.Clone()).ParseFiles(filepath.Join(templateDir, tf)) + if err != nil { + panic(err) + } + templates[tf] = t + } + + return &Admin{ + config: cfg, + db: database, + store: store, + templates: templates, + baseTemplate: baseTemplate, + } +} + +// RegisterRoutes registers admin routes on the given router +func (a *Admin) RegisterRoutes(mux *http.ServeMux) { + // Register admin routes + mux.HandleFunc("/admin/", a.handleIndex) + mux.HandleFunc("/admin/login", a.handleLogin) + mux.HandleFunc("/admin/logout", a.handleLogout) + mux.HandleFunc("/admin/plugins", a.handlePluginList) + mux.HandleFunc("/admin/channels", a.handleChannelList) + mux.HandleFunc("/admin/channels/", a.handleChannelDetail) + mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList) + mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete) +} + +// getCurrentUser gets the current user from the session +func (a *Admin) getCurrentUser(r *http.Request) *model.User { + session, _ := a.store.Get(r, sessionKey) + + // Check if user is logged in + userID, ok := session.Values["user_id"].(int64) + if !ok { + return nil + } + + // Get user from database + user, err := a.db.GetUserByID(userID) + if err != nil { + return nil + } + + return user +} + +// isLoggedIn checks if the user is logged in +func (a *Admin) isLoggedIn(r *http.Request) bool { + session, _ := a.store.Get(r, sessionKey) + return session.Values["logged_in"] == true +} + +// addFlash adds a flash message to the session +func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) { + session, _ := a.store.Get(r, sessionKey) + + // Add flash message + flashes := session.Flashes() + if flashes == nil { + flashes = make([]interface{}, 0) + } + + flash := FlashMessage{ + Category: category, + Message: message, + } + + session.AddFlash(flash) + session.Save(r, w) +} + +// getFlashes gets all flash messages from the session +func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage { + session, _ := a.store.Get(r, sessionKey) + + // Get flash messages + flashes := session.Flashes() + messages := make([]FlashMessage, 0, len(flashes)) + + for _, f := range flashes { + if flash, ok := f.(FlashMessage); ok { + messages = append(messages, flash) + } + } + + // Save session to clear flashes + session.Save(r, w) + + return messages +} + +// requireLogin middleware checks if the user is logged in +func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + next(w, r) + } +} + +// render renders a template with the given data +func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { + // Add current user data + data.User = a.getCurrentUser(r) + data.LoggedIn = a.isLoggedIn(r) + data.Path = r.URL.Path + data.Flash = a.getFlashes(w, r) + + // Get template + tmpl, ok := a.templates[templateName] + if !ok { + http.Error(w, "Template not found", http.StatusInternalServerError) + return + } + + // Render template + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// handleIndex handles the admin index route +func (a *Admin) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/admin/" { + http.NotFound(w, r) + return + } + + // Redirect to login if not logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Redirect to channel list + http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) +} + +// handleLogin handles the login route +func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { + // If already logged in, redirect to index + if a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + + // Handle login form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Check credentials + username := r.FormValue("username") + password := r.FormValue("password") + + user, err := a.db.CheckCredentials(username, password) + if err != nil || user == nil { + a.addFlash(w, r, "Incorrect credentials", "danger") + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Set session + session, _ := a.store.Get(r, sessionKey) + session.Values["logged_in"] = true + session.Values["user_id"] = user.ID + + // Set session expiration + session.Options.MaxAge = 3600 * 24 * 7 // 1 week + session.Save(r, w) + + a.addFlash(w, r, "You were logged in", "success") + + // Redirect to index + next := r.URL.Query().Get("next") + if next == "" { + next = "/admin/" + } + http.Redirect(w, r, next, http.StatusSeeOther) + return + } + + // Render login template + a.render(w, r, "login.html", TemplateData{ + Title: "Login", + }) +} + +// handleLogout handles the logout route +func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { + // Clear session + session, _ := a.store.Get(r, sessionKey) + session.Values = make(map[interface{}]interface{}) + session.Options.MaxAge = -1 // Delete session + session.Save(r, w) + + a.addFlash(w, r, "You were logged out", "success") + + // Redirect to login + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +// handlePluginList handles the plugin list route +func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Get available plugins + plugins := plugin.GetAvailablePlugins() + + // Render template + a.render(w, r, "plugin_list.html", TemplateData{ + Title: "Plugins", + Plugins: plugins, + }) +} + +// handleChannelList handles the channel list route +func (a *Admin) handleChannelList(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Get all channels + channels, err := a.db.GetAllChannels() + if err != nil { + http.Error(w, "Failed to get channels", http.StatusInternalServerError) + return + } + + // Render template + a.render(w, r, "channel_list.html", TemplateData{ + Title: "Channels", + Channels: channels, + }) +} + +// handleChannelDetail handles the channel detail route +func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Extract channel ID from path + path := r.URL.Path + if path == "/admin/channels/" { + http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) + return + } + + channelID := strings.TrimPrefix(path, "/admin/channels/") + if strings.Contains(channelID, "/") { + // Handle delete request + if strings.HasSuffix(path, "/delete") && r.Method == http.MethodPost { + channelID = strings.TrimSuffix(channelID, "/delete") + + // Delete channel + id, err := strconv.ParseInt(channelID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel ID", http.StatusBadRequest) + return + } + + if err := a.db.DeleteChannel(id); err != nil { + http.Error(w, "Failed to delete channel", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Channel removed", "success") + http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) + return + } + + http.NotFound(w, r) + return + } + + // Convert channel ID to int64 + id, err := strconv.ParseInt(channelID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel ID", http.StatusBadRequest) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Check if the form was submitted + if r.FormValue("form_submitted") == "true" { + // Update channel + enabled := r.FormValue("enabled") == "true" + if err := a.db.UpdateChannel(id, enabled); err != nil { + http.Error(w, "Failed to update channel", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Channel updated", "success") + http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther) + return + } + } + + // Get channel + channel, err := a.db.GetChannelByID(id) + if err != nil { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + + // Get available plugins + plugins := plugin.GetAvailablePlugins() + + // Render template + a.render(w, r, "channel_detail.html", TemplateData{ + Title: "Channel: " + channel.PlatformChannelID, + Channel: channel, + Plugins: plugins, + }) +} + +// handleChannelPluginList handles the channel plugin list route +func (a *Admin) handleChannelPluginList(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Extract form data + channelID, err := strconv.ParseInt(r.FormValue("channel_id"), 10, 64) + if err != nil { + http.Error(w, "Invalid channel ID", http.StatusBadRequest) + return + } + + pluginID := r.FormValue("plugin_id") + enabled := r.FormValue("enabled") == "y" + + // Create channel plugin + config := make(map[string]interface{}) + _, err = a.db.CreateChannelPlugin(channelID, pluginID, enabled, config) + if err == db.ErrDuplicated { + a.addFlash(w, r, "Plugin "+pluginID+" is already present on the channel", "danger") + } else if err != nil { + http.Error(w, "Failed to create channel plugin", http.StatusInternalServerError) + return + } else { + a.addFlash(w, r, "Plugin "+pluginID+" added to the channel", "success") + } + + // Redirect back + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/admin/channelplugins" + } + http.Redirect(w, r, referer, http.StatusSeeOther) + return + } + + // Get all channels + channels, err := a.db.GetAllChannels() + if err != nil { + http.Error(w, "Failed to get channels", http.StatusInternalServerError) + return + } + + // Render template + a.render(w, r, "channel_plugins_list.html", TemplateData{ + Title: "Channel Plugins", + Channels: channels, + Plugins: plugin.GetAvailablePlugins(), + }) +} + +// handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route +func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Extract channel plugin ID from path + path := r.URL.Path + if path == "/admin/channelplugins/" { + http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) + return + } + + channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/") + + // Handle delete request + if strings.HasSuffix(channelPluginID, "/delete") && r.Method == http.MethodPost { + channelPluginID = strings.TrimSuffix(channelPluginID, "/delete") + + // Delete channel plugin + id, err := strconv.ParseInt(channelPluginID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) + return + } + + if err := a.db.DeleteChannelPlugin(id); err != nil { + http.Error(w, "Failed to delete channel plugin", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Plugin removed", "success") + + // Redirect back + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/admin/channelplugins" + } + http.Redirect(w, r, referer, http.StatusSeeOther) + return + } + + // Handle update request + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Convert channel plugin ID to int64 + id, err := strconv.ParseInt(channelPluginID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) + return + } + + // Update channel plugin + enabled := r.FormValue("enabled") == "true" + if err := a.db.UpdateChannelPlugin(id, enabled); err != nil { + http.Error(w, "Failed to update channel plugin", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Plugin updated", "success") + + // Redirect back + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/admin/channelplugins" + } + http.Redirect(w, r, referer, http.StatusSeeOther) + return + } + + // Redirect to channel plugins list + http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) +} diff --git a/butterrobot/admin/templates/_base.j2 b/internal/admin/templates/_base.html similarity index 79% rename from butterrobot/admin/templates/_base.j2 rename to internal/admin/templates/_base.html index d1db800..d056ab5 100644 --- a/butterrobot/admin/templates/_base.j2 +++ b/internal/admin/templates/_base.html @@ -4,7 +4,7 @@ - ButterRobot Admin + {{.Title}} - ButterRobot Admin @@ -24,27 +24,27 @@ - {% if session.logged_in %} + {{if .LoggedIn}} + + - + \ No newline at end of file diff --git a/internal/admin/templates/channel_detail.html b/internal/admin/templates/channel_detail.html new file mode 100644 index 0000000..764d7b1 --- /dev/null +++ b/internal/admin/templates/channel_detail.html @@ -0,0 +1,114 @@ +{{define "content"}} +
+ +
+
+
+

Channel Plugins

+
+
+
+ + + + + + + + + + {{range $pluginID, $channelPlugin := .Channel.Plugins}} + + + + + + {{else}} + + + + {{end}} + +
PluginEnabledActions
{{$pluginID}} + {{if $channelPlugin.Enabled}} + Enabled + {{else}} + Disabled + {{end}} + +
+ + +
+
+ +
+
No plugins for this channel
+
+ +
+ +

Add Plugin

+
+ +
+ + +
+
+ +
+ +
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/admin/templates/channel_list.html b/internal/admin/templates/channel_list.html new file mode 100644 index 0000000..c13f2fc --- /dev/null +++ b/internal/admin/templates/channel_list.html @@ -0,0 +1,64 @@ +{{define "content"}} +
+
+
+
+

Channels

+
+
+
+ + + + + + + + + + + + + + {{range .Channels}} + + + + + + + + + + {{else}} + + + + {{end}} + +
IDPlatformChannel IDNameEnabledPluginsActions
{{.ID}}{{.Platform}}{{.PlatformChannelID}}{{.ChannelName}} + {{if .Enabled}} + Enabled + {{else}} + Disabled + {{end}} + + {{$count := len .Plugins}} + {{if eq $count 0}} + No plugins + {{else}} + {{$count}} plugins + {{end}} + + Edit +
+ +
+
No channels found
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/admin/templates/channel_plugins_list.html b/internal/admin/templates/channel_plugins_list.html new file mode 100644 index 0000000..b57c60e --- /dev/null +++ b/internal/admin/templates/channel_plugins_list.html @@ -0,0 +1,93 @@ +{{define "content"}} +
+
+
+
+

Channel Plugins

+
+
+
+ + + + + + + + + + + + {{range .Channels}} + {{range $pluginID, $channelPlugin := .Plugins}} + + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
IDChannelPluginEnabledActions
{{$channelPlugin.ID}}{{.ChannelName}}{{$pluginID}} + {{if $channelPlugin.Enabled}} + Enabled + {{else}} + Disabled + {{end}} + +
+ + +
+
+ +
+
No channel plugins found
+
+ +
+ +

Add Plugin to Channel

+
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/admin/templates/index.html b/internal/admin/templates/index.html new file mode 100644 index 0000000..c352721 --- /dev/null +++ b/internal/admin/templates/index.html @@ -0,0 +1,15 @@ +{{define "content"}} +
+
+
+
+

ButterRobot Admin

+
+
+

Welcome to the ButterRobot admin interface.

+

Use the navigation above to manage channels and plugins.

+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/admin/templates/login.html b/internal/admin/templates/login.html new file mode 100644 index 0000000..2484a5c --- /dev/null +++ b/internal/admin/templates/login.html @@ -0,0 +1,26 @@ +{{define "content"}} +
+
+
+
+

Login

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/admin/templates/plugin_list.html b/internal/admin/templates/plugin_list.html new file mode 100644 index 0000000..bbb860c --- /dev/null +++ b/internal/admin/templates/plugin_list.html @@ -0,0 +1,45 @@ +{{define "content"}} +
+
+
+
+

Available Plugins

+
+
+
+ + + + + + + + + + + {{range $id, $plugin := .Plugins}} + + + + + + + {{else}} + + + + {{end}} + +
IDNameHelpRequires Config
{{$id}}{{$plugin.GetName}}{{$plugin.GetHelp}} + {{if $plugin.RequiresConfig}} + Yes + {{else}} + No + {{end}} +
No plugins found
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..8d4ffcd --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,293 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/admin" + "git.nakama.town/fmartingr/butterrobot/internal/config" + "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/platform" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" + "git.nakama.town/fmartingr/butterrobot/internal/queue" +) + +// App represents the application +type App struct { + config *config.Config + logger *slog.Logger + db *db.Database + router *http.ServeMux + queue *queue.Queue + admin *admin.Admin +} + +// New creates a new App instance +func New(cfg *config.Config, logger *slog.Logger) (*App, error) { + // Initialize router + router := http.NewServeMux() + + // Initialize database + database, err := db.New(cfg.DatabasePath) + if err != nil { + return nil, fmt.Errorf("failed to initialize database: %w", err) + } + + // Initialize message queue + messageQueue := queue.New(logger) + + // Initialize admin interface + adminInterface := admin.New(cfg, database) + + return &App{ + config: cfg, + logger: logger, + db: database, + router: router, + queue: messageQueue, + admin: adminInterface, + }, nil +} + +// Run starts the application +func (a *App) Run() error { + // Initialize platforms + if err := platform.InitializePlatforms(a.config); err != nil { + return err + } + + // Register built-in plugins + plugin.Register(ping.New()) + plugin.Register(fun.NewCoin()) + plugin.Register(fun.NewDice()) + plugin.Register(fun.NewLoquito()) + + // Initialize routes + a.initializeRoutes() + + // Start message queue worker + a.queue.Start(a.handleMessage) + + // Create server + addr := fmt.Sprintf(":%s", a.config.Port) + srv := &http.Server{ + Addr: addr, + Handler: a.router, + } + + // Start server in a goroutine + go func() { + a.logger.Info("Server starting on", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + a.logger.Error("Server error", "error", err) + os.Exit(1) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + a.logger.Info("Shutting down server...") + + // Create shutdown context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Shutdown server + if err := srv.Shutdown(ctx); err != nil { + return err + } + + // Stop message queue + a.queue.Stop() + + // Close database connection + if err := a.db.Close(); err != nil { + return err + } + + a.logger.Info("Server stopped") + + return nil +} + +// Initialize HTTP routes +func (a *App) initializeRoutes() { + // Health check endpoint + a.router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{}) + }) + + // Platform webhook endpoints + for name := range platform.GetAvailablePlatforms() { + a.logger.Info("Registering webhook endpoint for platform", "platform", name) + platformName := name // Create a copy to avoid closure issues + a.router.HandleFunc("/"+platformName+"/incoming/", a.handleIncomingWebhook) + } + + // Register admin routes + a.admin.RegisterRoutes(a.router) +} + +// Handle incoming webhook +func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { + // Extract platform name from path + platformName := extractPlatformName(r.URL.Path) + + // Check if platform exists + if _, err := platform.Get(platformName); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}) + return + } + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}) + return + } + + // Queue message for processing + a.queue.Add(queue.Item{ + Platform: platformName, + Request: map[string]any{ + "path": r.URL.Path, + "json": json.RawMessage(body), + }, + }) + + // Respond with success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{}) +} + +// extractPlatformName extracts the platform name from the URL path +func extractPlatformName(path string) string { + // Remove leading slash + path = strings.TrimPrefix(path, "/") + + // Split by slash + parts := strings.Split(path, "/") + + // First part is the platform name + if len(parts) > 0 { + // Special case for Telegram with token in the URL + if parts[0] == "telegram" && len(parts) > 1 && parts[1] == "incoming" { + return "telegram" + } + return parts[0] + } + + return "" +} + +// Handle message processing +func (a *App) handleMessage(item queue.Item) { + // Get platform + p, err := platform.Get(item.Platform) + if err != nil { + a.logger.Error("Error getting platform", "error", err) + return + } + + // Create a new request with the body + bodyJSON, ok := item.Request["json"].(json.RawMessage) + if !ok { + a.logger.Error("Invalid JSON in request") + return + } + + reqPath, ok := item.Request["path"].(string) + if !ok { + a.logger.Error("Invalid path in request") + return + } + + req, err := http.NewRequest("POST", reqPath, strings.NewReader(string(bodyJSON))) + if err != nil { + a.logger.Error("Error creating request", "error", err) + return + } + + req.Header.Set("Content-Type", "application/json") + + // Parse message + message, err := p.ParseIncomingMessage(req) + if err != nil { + a.logger.Error("Error parsing message", "error", err) + return + } + + // Skip if message is from a bot + if message == nil || message.FromBot { + return + } + + // Get or create channel + channel, err := a.db.GetChannelByPlatform(item.Platform, message.Chat) + if err == db.ErrNotFound { + channel, err = a.db.CreateChannel(item.Platform, message.Chat, false, message.Channel.ChannelRaw) + if err != nil { + a.logger.Error("Error creating channel", "error", err) + return + } + } else if err != nil { + a.logger.Error("Error getting channel", "error", err) + return + } + + // Skip if channel is disabled + if !channel.Enabled { + return + } + + // Process message with plugins + for pluginID, channelPlugin := range channel.Plugins { + if !channel.HasEnabledPlugin(pluginID) { + continue + } + + // Get plugin + p, err := plugin.Get(pluginID) + if err != nil { + a.logger.Error("Error getting plugin", "error", err) + continue + } + + // Process message + responses := p.OnMessage(message, channelPlugin.Config) + + // Send responses + platform, err := platform.Get(item.Platform) + if err != nil { + a.logger.Error("Error getting platform", "error", err) + continue + } + + for _, response := range responses { + if err := platform.SendMessage(response); err != nil { + a.logger.Error("Error sending message", "error", err) + } + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b10f653 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "os" + "strings" +) + +// Config holds all application configuration +type Config struct { + Debug bool + Hostname string + Port string + LogLevel string + SecretKey string + DatabasePath string + SlackConfig SlackConfig + TelegramConfig TelegramConfig +} + +// SlackConfig holds Slack platform configuration +type SlackConfig struct { + Token string + BotOAuthAccessToken string +} + +// TelegramConfig holds Telegram platform configuration +type TelegramConfig struct { + Token string +} + +// Load loads configuration from environment variables +func Load() (*Config, error) { + config := &Config{ + Debug: getEnv("DEBUG", "n") == "y", + Hostname: getEnv("BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network"), + Port: getEnv("PORT", "8080"), + LogLevel: getEnv("LOG_LEVEL", "ERROR"), + SecretKey: getEnv("SECRET_KEY", "1234"), + DatabasePath: getEnv("DATABASE_PATH", "butterrobot.db"), + SlackConfig: SlackConfig{ + Token: getEnv("SLACK_TOKEN", ""), + BotOAuthAccessToken: getEnv("SLACK_BOT_OAUTH_ACCESS_TOKEN", ""), + }, + TelegramConfig: TelegramConfig{ + Token: getEnv("TELEGRAM_TOKEN", ""), + }, + } + + return config, nil +} + +// getEnv retrieves an environment variable value or returns a default value +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if strings.TrimSpace(value) == "" { + return defaultValue + } + return value +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..e288bb3 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,641 @@ +package db + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + + _ "modernc.org/sqlite" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +var ( + // ErrNotFound is returned when a record is not found + ErrNotFound = errors.New("record not found") + + // ErrDuplicated is returned when a record already exists + ErrDuplicated = errors.New("record already exists") +) + +// Database handles database operations +type Database struct { + db *sql.DB +} + +// New creates a new Database instance +func New(dbPath string) (*Database, error) { + // Open database connection + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + // Initialize database + if err := initDatabase(db); err != nil { + return nil, err + } + + return &Database{db: db}, nil +} + +// Close closes the database connection +func (d *Database) Close() error { + return d.db.Close() +} + +// GetChannelByID retrieves a channel by ID +func (d *Database) GetChannelByID(id int64) (*model.Channel, error) { + query := ` + SELECT id, platform, platform_channel_id, enabled, channel_raw + FROM channels + WHERE id = ? + ` + + row := d.db.QueryRow(query, id) + + var ( + platform string + platformChannelID string + enabled bool + channelRawJSON string + ) + + err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + // Parse channel_raw JSON + var channelRaw map[string]interface{} + if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil { + return nil, err + } + + // Create channel + channel := &model.Channel{ + ID: id, + Platform: platform, + PlatformChannelID: platformChannelID, + Enabled: enabled, + ChannelRaw: channelRaw, + Plugins: make(map[string]*model.ChannelPlugin), + } + + // Get channel plugins + plugins, err := d.GetChannelPlugins(id) + if err != nil && err != ErrNotFound { + return nil, err + } + + for _, plugin := range plugins { + channel.Plugins[plugin.PluginID] = plugin + } + + return channel, nil +} + +// GetChannelByPlatform retrieves a channel by platform and platform channel ID +func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*model.Channel, error) { + query := ` + SELECT id, platform, platform_channel_id, enabled, channel_raw + FROM channels + WHERE platform = ? AND platform_channel_id = ? + ` + + row := d.db.QueryRow(query, platform, platformChannelID) + + var ( + id int64 + enabled bool + channelRawJSON string + ) + + err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + // Parse channel_raw JSON + var channelRaw map[string]interface{} + if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil { + return nil, err + } + + // Create channel + channel := &model.Channel{ + ID: id, + Platform: platform, + PlatformChannelID: platformChannelID, + Enabled: enabled, + ChannelRaw: channelRaw, + Plugins: make(map[string]*model.ChannelPlugin), + } + + // Get channel plugins + plugins, err := d.GetChannelPlugins(id) + if err != nil && err != ErrNotFound { + return nil, err + } + + for _, plugin := range plugins { + channel.Plugins[plugin.PluginID] = plugin + } + + return channel, nil +} + +// CreateChannel creates a new channel +func (d *Database) CreateChannel(platform, platformChannelID string, enabled bool, channelRaw map[string]interface{}) (*model.Channel, error) { + // Convert channelRaw to JSON + channelRawJSON, err := json.Marshal(channelRaw) + if err != nil { + return nil, err + } + + // Insert channel + query := ` + INSERT INTO channels (platform, platform_channel_id, enabled, channel_raw) + VALUES (?, ?, ?, ?) + ` + + result, err := d.db.Exec(query, platform, platformChannelID, enabled, string(channelRawJSON)) + if err != nil { + return nil, err + } + + // Get inserted ID + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + // Create channel + channel := &model.Channel{ + ID: id, + Platform: platform, + PlatformChannelID: platformChannelID, + Enabled: enabled, + ChannelRaw: channelRaw, + Plugins: make(map[string]*model.ChannelPlugin), + } + + return channel, nil +} + +// UpdateChannel updates a channel's enabled status +func (d *Database) UpdateChannel(id int64, enabled bool) error { + query := ` + UPDATE channels + SET enabled = ? + WHERE id = ? + ` + + _, err := d.db.Exec(query, enabled, id) + return err +} + +// DeleteChannel deletes a channel +func (d *Database) DeleteChannel(id int64) error { + // First delete all channel plugins + if err := d.DeleteChannelPluginsByChannel(id); err != nil { + return err + } + + // Then delete the channel + query := ` + DELETE FROM channels + WHERE id = ? + ` + + _, err := d.db.Exec(query, id) + return err +} + +// GetChannelPlugins retrieves all plugins for a channel +func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) { + query := ` + SELECT id, channel_id, plugin_id, enabled, config + FROM channel_plugin + WHERE channel_id = ? + ` + + rows, err := d.db.Query(query, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var plugins []*model.ChannelPlugin + + for rows.Next() { + var ( + id int64 + channelID int64 + pluginID string + enabled bool + configJSON string + ) + + if err := rows.Scan(&id, &channelID, &pluginID, &enabled, &configJSON); err != nil { + return nil, err + } + + // Parse config JSON + var config map[string]interface{} + if err := json.Unmarshal([]byte(configJSON), &config); err != nil { + return nil, err + } + + plugin := &model.ChannelPlugin{ + ID: id, + ChannelID: channelID, + PluginID: pluginID, + Enabled: enabled, + Config: config, + } + + plugins = append(plugins, plugin) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + if len(plugins) == 0 { + return nil, ErrNotFound + } + + return plugins, nil +} + +// GetChannelPluginByID retrieves a channel plugin by ID +func (d *Database) GetChannelPluginByID(id int64) (*model.ChannelPlugin, error) { + query := ` + SELECT id, channel_id, plugin_id, enabled, config + FROM channel_plugin + WHERE id = ? + ` + + row := d.db.QueryRow(query, id) + + var ( + channelID int64 + pluginID string + enabled bool + configJSON string + ) + + err := row.Scan(&id, &channelID, &pluginID, &enabled, &configJSON) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + // Parse config JSON + var config map[string]interface{} + if err := json.Unmarshal([]byte(configJSON), &config); err != nil { + return nil, err + } + + return &model.ChannelPlugin{ + ID: id, + ChannelID: channelID, + PluginID: pluginID, + Enabled: enabled, + Config: config, + }, nil +} + +// CreateChannelPlugin creates a new channel plugin +func (d *Database) CreateChannelPlugin(channelID int64, pluginID string, enabled bool, config map[string]interface{}) (*model.ChannelPlugin, error) { + // Check if plugin already exists for this channel + query := ` + SELECT COUNT(*) + FROM channel_plugin + WHERE channel_id = ? AND plugin_id = ? + ` + + var count int + err := d.db.QueryRow(query, channelID, pluginID).Scan(&count) + if err != nil { + return nil, err + } + + if count > 0 { + return nil, ErrDuplicated + } + + // Convert config to JSON + configJSON, err := json.Marshal(config) + if err != nil { + return nil, err + } + + // Insert channel plugin + insertQuery := ` + INSERT INTO channel_plugin (channel_id, plugin_id, enabled, config) + VALUES (?, ?, ?, ?) + ` + + result, err := d.db.Exec(insertQuery, channelID, pluginID, enabled, string(configJSON)) + if err != nil { + return nil, err + } + + // Get inserted ID + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + return &model.ChannelPlugin{ + ID: id, + ChannelID: channelID, + PluginID: pluginID, + Enabled: enabled, + Config: config, + }, nil +} + +// UpdateChannelPlugin updates a channel plugin's enabled status +func (d *Database) UpdateChannelPlugin(id int64, enabled bool) error { + query := ` + UPDATE channel_plugin + SET enabled = ? + WHERE id = ? + ` + + _, err := d.db.Exec(query, enabled, id) + return err +} + +// DeleteChannelPlugin deletes a channel plugin +func (d *Database) DeleteChannelPlugin(id int64) error { + query := ` + DELETE FROM channel_plugin + WHERE id = ? + ` + + _, err := d.db.Exec(query, id) + return err +} + +// DeleteChannelPluginsByChannel deletes all plugins for a channel +func (d *Database) DeleteChannelPluginsByChannel(channelID int64) error { + query := ` + DELETE FROM channel_plugin + WHERE channel_id = ? + ` + + _, err := d.db.Exec(query, channelID) + return err +} + +// GetAllChannels retrieves all channels +func (d *Database) GetAllChannels() ([]*model.Channel, error) { + query := ` + SELECT id, platform, platform_channel_id, enabled, channel_raw + FROM channels + ` + + rows, err := d.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var channels []*model.Channel + + for rows.Next() { + var ( + id int64 + platform string + platformChannelID string + enabled bool + channelRawJSON string + ) + + if err := rows.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON); err != nil { + return nil, err + } + + // Parse channel_raw JSON + var channelRaw map[string]interface{} + if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil { + return nil, err + } + + // Create channel + channel := &model.Channel{ + ID: id, + Platform: platform, + PlatformChannelID: platformChannelID, + Enabled: enabled, + ChannelRaw: channelRaw, + Plugins: make(map[string]*model.ChannelPlugin), + } + + // Get channel plugins + plugins, err := d.GetChannelPlugins(id) + if err != nil && err != ErrNotFound { + continue // Skip this channel if plugins can't be retrieved + } + + if plugins != nil { + for _, plugin := range plugins { + channel.Plugins[plugin.PluginID] = plugin + } + } + + channels = append(channels, channel) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + if len(channels) == 0 { + channels = make([]*model.Channel, 0) + } + + return channels, nil +} + +// GetUserByID retrieves a user by ID +func (d *Database) GetUserByID(id int64) (*model.User, error) { + query := ` + SELECT id, username, password + FROM users + WHERE id = ? + ` + + row := d.db.QueryRow(query, id) + + var ( + username string + password string + ) + + err := row.Scan(&id, &username, &password) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + return &model.User{ + ID: id, + Username: username, + Password: password, + }, nil +} + +// CreateUser creates a new user +func (d *Database) CreateUser(username, password string) (*model.User, error) { + // Hash password + hashedPassword := hashPassword(password) + + // Insert user + query := ` + INSERT INTO users (username, password) + VALUES (?, ?) + ` + + result, err := d.db.Exec(query, username, hashedPassword) + if err != nil { + return nil, err + } + + // Get inserted ID + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + return &model.User{ + ID: id, + Username: username, + Password: hashedPassword, + }, nil +} + +// CheckCredentials checks if the username and password are valid +func (d *Database) CheckCredentials(username, password string) (*model.User, error) { + query := ` + SELECT id, username, password + FROM users + WHERE username = ? + ` + + row := d.db.QueryRow(query, username) + + var ( + id int64 + dbUsername string + dbPassword string + ) + + err := row.Scan(&id, &dbUsername, &dbPassword) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + // Check password + hashedPassword := hashPassword(password) + if dbPassword != hashedPassword { + return nil, errors.New("invalid credentials") + } + + return &model.User{ + ID: id, + Username: dbUsername, + Password: dbPassword, + }, nil +} + +// Helper function to hash password +func hashPassword(password string) string { + // In a real implementation, use a proper password hashing library like bcrypt + // This is a simplified version for demonstration + hasher := sha256.New() + hasher.Write([]byte(password)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +// Initialize database tables +func initDatabase(db *sql.DB) error { + // Create channels table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_channel_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + channel_raw TEXT NOT NULL, + UNIQUE(platform, platform_channel_id) + ) + `) + if err != nil { + return err + } + + // Create channel_plugin table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS channel_plugin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + UNIQUE(channel_id, plugin_id), + FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE + ) + `) + if err != nil { + return err + } + + // Create users table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL + ) + `) + if err != nil { + return err + } + + // Create default admin user if it doesn't exist + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if err != nil { + return err + } + + if count == 0 { + hashedPassword := hashPassword("admin") + _, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", "admin", hashedPassword) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/model/message.go b/internal/model/message.go new file mode 100644 index 0000000..fe8c5e4 --- /dev/null +++ b/internal/model/message.go @@ -0,0 +1,86 @@ +package model + +import ( + "time" +) + +// Message represents a chat message +type Message struct { + Text string + Chat string + Channel *Channel + Author string + FromBot bool + Date time.Time + ID string + ReplyTo string + Raw map[string]interface{} +} + +// Channel represents a chat channel +type Channel struct { + ID int64 + Platform string + PlatformChannelID string + ChannelRaw map[string]interface{} + Enabled bool + Plugins map[string]*ChannelPlugin +} + +// HasEnabledPlugin checks if a plugin is enabled for this channel +func (c *Channel) HasEnabledPlugin(pluginID string) bool { + plugin, exists := c.Plugins[pluginID] + if !exists { + return false + } + return plugin.Enabled +} + +// ChannelName returns the channel name +func (c *Channel) ChannelName() string { + // In a real implementation, this would use the platform-specific + // ParseChannelNameFromRaw function + + // For simplicity, we'll just use the PlatformChannelID if we can't extract a name + // Check if ChannelRaw has a name field + if c.ChannelRaw == nil { + return c.PlatformChannelID + } + + // Check common name fields in ChannelRaw + if name, ok := c.ChannelRaw["name"].(string); ok && name != "" { + return name + } + + // Check for nested objects like "chat" (used by Telegram) + if chat, ok := c.ChannelRaw["chat"].(map[string]interface{}); ok { + // Try different fields in order of preference + if title, ok := chat["title"].(string); ok && title != "" { + return title + } + if username, ok := chat["username"].(string); ok && username != "" { + return username + } + if firstName, ok := chat["first_name"].(string); ok && firstName != "" { + return firstName + } + } + + return c.PlatformChannelID +} + +// ChannelPlugin represents a plugin enabled for a channel +type ChannelPlugin struct { + ID int64 + ChannelID int64 + PluginID string + Enabled bool + Config map[string]interface{} +} + +// User represents an admin user +type User struct { + ID int64 + Username string + Password string +} \ No newline at end of file diff --git a/internal/model/platform.go b/internal/model/platform.go new file mode 100644 index 0000000..01318eb --- /dev/null +++ b/internal/model/platform.go @@ -0,0 +1,46 @@ +package model + +import ( + "errors" + "net/http" + + "git.nakama.town/fmartingr/butterrobot/internal/config" +) + +var ( + // ErrPlatform is a general platform error + ErrPlatform = errors.New("platform error") + + // ErrPlatformInit is an error during platform initialization + ErrPlatformInit = errors.New("platform initialization error") + + // ErrPlatformAuth is an authentication error + ErrPlatformAuth = errors.New("platform authentication error") + + // ErrPlatformNotFound is returned when a requested platform doesn't exist + ErrPlatformNotFound = errors.New("platform not found") +) + +// AuthResponse represents a platform authentication response +type AuthResponse struct { + Data map[string]any + StatusCode int +} + +// Platform defines the interface all chat platforms must implement +type Platform interface { + // Init initializes the platform + Init(cfg *config.Config) error + + // ParseIncomingMessage parses the incoming HTTP request into a Message + ParseIncomingMessage(r *http.Request) (*Message, error) + + // ParseChannelNameFromRaw extracts a human-readable channel name from raw data + ParseChannelNameFromRaw(channelRaw map[string]any) string + + // ParseChannelFromMessage extracts channel data from a message + ParseChannelFromMessage(body []byte) (map[string]any, error) + + // SendMessage sends a message through the platform + SendMessage(msg *Message) error +} diff --git a/internal/model/plugin.go b/internal/model/plugin.go new file mode 100644 index 0000000..ffc3c2f --- /dev/null +++ b/internal/model/plugin.go @@ -0,0 +1,28 @@ +package model + +import ( + "errors" +) + +var ( + // ErrPluginNotFound is returned when a requested plugin doesn't exist + ErrPluginNotFound = errors.New("plugin not found") +) + +// Plugin defines the interface all chat plugins must implement +type Plugin interface { + // GetID returns the plugin ID + GetID() string + + // GetName returns the plugin name + GetName() string + + // GetHelp returns the plugin help text + GetHelp() string + + // RequiresConfig indicates if the plugin requires configuration + RequiresConfig() bool + + // OnMessage processes an incoming message and returns response messages + OnMessage(msg *Message, config map[string]interface{}) []*Message +} \ No newline at end of file diff --git a/internal/platform/init.go b/internal/platform/init.go new file mode 100644 index 0000000..63625ca --- /dev/null +++ b/internal/platform/init.go @@ -0,0 +1,32 @@ +package platform + +import ( + "fmt" + + "git.nakama.town/fmartingr/butterrobot/internal/config" + "git.nakama.town/fmartingr/butterrobot/internal/platform/slack" + "git.nakama.town/fmartingr/butterrobot/internal/platform/telegram" +) + +// InitializePlatforms initializes all available platforms +func InitializePlatforms(cfg *config.Config) error { + // Initialize Slack platform + if cfg.SlackConfig.Token != "" && cfg.SlackConfig.BotOAuthAccessToken != "" { + slackPlatform := slack.New(&cfg.SlackConfig) + if err := slackPlatform.Init(cfg); err == nil { + Register("slack", slackPlatform) + } + } + + // Initialize Telegram platform + if cfg.TelegramConfig.Token != "" { + telegramPlatform := telegram.New(&cfg.TelegramConfig) + if err := telegramPlatform.Init(cfg); err == nil { + Register("telegram", telegramPlatform) + } + } else { + return fmt.Errorf("telegram token is required") + } + + return nil +} diff --git a/internal/platform/registry.go b/internal/platform/registry.go new file mode 100644 index 0000000..b6ce05c --- /dev/null +++ b/internal/platform/registry.go @@ -0,0 +1,49 @@ +package platform + +import ( + "sync" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +var ( + // platforms holds all registered chat platforms + platforms = make(map[string]model.Platform) + + // platformsMu protects the platforms map + platformsMu sync.RWMutex +) + +// Register registers a platform with the given ID +func Register(id string, platform model.Platform) { + platformsMu.Lock() + defer platformsMu.Unlock() + platforms[id] = platform +} + +// Get returns a platform by ID +func Get(id string) (model.Platform, error) { + platformsMu.RLock() + defer platformsMu.RUnlock() + + platform, exists := platforms[id] + if !exists { + return nil, model.ErrPlatformNotFound + } + + return platform, nil +} + +// GetAvailablePlatforms returns all registered platforms +func GetAvailablePlatforms() map[string]model.Platform { + platformsMu.RLock() + defer platformsMu.RUnlock() + + // Create a copy to avoid race conditions + result := make(map[string]model.Platform, len(platforms)) + for id, platform := range platforms { + result[id] = platform + } + + return result +} diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go new file mode 100644 index 0000000..3683ada --- /dev/null +++ b/internal/platform/slack/slack.go @@ -0,0 +1,212 @@ +package slack + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/config" + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +// SlackPlatform implements the Platform interface for Slack +type SlackPlatform struct { + config *config.SlackConfig +} + +// New creates a new SlackPlatform instance +func New(cfg *config.SlackConfig) *SlackPlatform { + return &SlackPlatform{ + config: cfg, + } +} + +// Init initializes the Slack platform +func (s *SlackPlatform) Init(_ *config.Config) error { + // Validate config + if s.config.Token == "" || s.config.BotOAuthAccessToken == "" { + return model.ErrPlatformInit + } + return nil +} + +// ParseIncomingMessage parses an incoming Slack message +func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) { + // Read request body + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, err + } + defer r.Body.Close() + + // Parse JSON + var requestData map[string]interface{} + if err := json.Unmarshal(body, &requestData); err != nil { + return nil, err + } + + // Verify Slack request + // This is a simplified version, production should include signature verification + urlVerify, ok := requestData["type"] + if ok && urlVerify == "url_verification" { + return nil, errors.New("url verification") // Handle separately + } + + // Process event + event, ok := requestData["event"].(map[string]interface{}) + if !ok { + return nil, errors.New("invalid event") + } + + // Create message + msg := &model.Message{ + Raw: requestData, + } + + // Get text + if text, ok := event["text"].(string); ok { + msg.Text = text + } + + // Get channel + if channel, ok := event["channel"].(string); ok { + msg.Chat = channel + + // Create Channel object + channelRaw, err := s.ParseChannelFromMessage(body) + if err != nil { + return nil, err + } + + msg.Channel = &model.Channel{ + Platform: "slack", + PlatformChannelID: channel, + ChannelRaw: channelRaw, + } + } + + // Check if from bot + if botID, ok := event["bot_id"].(string); ok && botID != "" { + msg.FromBot = true + } + + // Get user + if user, ok := event["user"].(string); ok { + msg.Author = user + } + + // Get timestamp + if ts, ok := event["ts"].(string); ok { + // Convert Unix timestamp + parts := strings.Split(ts, ".") + if len(parts) > 0 { + if sec, err := parseInt64(parts[0]); err == nil { + msg.Date = time.Unix(sec, 0) + msg.ID = ts + } + } + } + + return msg, nil +} + +// ParseChannelNameFromRaw extracts a human-readable channel name from raw data +func (s *SlackPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string { + // Extract name from channel raw data + if name, ok := channelRaw["name"].(string); ok { + return name + } + + // Fallback to ID if available + if id, ok := channelRaw["id"].(string); ok { + return id + } + + return "unknown" +} + +// ParseChannelFromMessage extracts channel data from a message +func (s *SlackPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) { + // Parse JSON + var requestData map[string]interface{} + if err := json.Unmarshal(body, &requestData); err != nil { + return nil, err + } + + // Extract channel info from event + event, ok := requestData["event"].(map[string]interface{}) + if !ok { + return nil, errors.New("invalid event data") + } + + channelID, ok := event["channel"].(string) + if !ok { + return nil, errors.New("channel ID not found") + } + + // In a real implementation, you might want to fetch more details about the channel + // using the Slack API, but for simplicity we'll just return the ID + channelRaw := map[string]interface{}{ + "id": channelID, + } + + return channelRaw, nil +} + +// SendMessage sends a message to Slack +func (s *SlackPlatform) SendMessage(msg *model.Message) error { + if s.config.BotOAuthAccessToken == "" { + return errors.New("bot token not configured") + } + + // Prepare payload + payload := map[string]interface{}{ + "channel": msg.Chat, + "text": msg.Text, + } + + // Add thread_ts if it's a reply + if msg.ReplyTo != "" { + payload["thread_ts"] = msg.ReplyTo + } + + // Convert payload to JSON + data, err := json.Marshal(payload) + if err != nil { + return err + } + + // Send HTTP request + req, err := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", strings.NewReader(string(data))) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.BotOAuthAccessToken)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check response + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("slack API error: %d", resp.StatusCode) + } + + return nil +} + +// Helper function to parse int64 +func parseInt64(s string) (int64, error) { + var n int64 + _, err := fmt.Sscanf(s, "%d", &n) + return n, err +} diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go new file mode 100644 index 0000000..a9ff2db --- /dev/null +++ b/internal/platform/telegram/telegram.go @@ -0,0 +1,262 @@ +package telegram + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strconv" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/config" + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +// TelegramPlatform implements the Platform interface for Telegram +type TelegramPlatform struct { + config *config.TelegramConfig + apiURL string + log *slog.Logger +} + +// New creates a new TelegramPlatform instance +func New(cfg *config.TelegramConfig) *TelegramPlatform { + return &TelegramPlatform{ + config: cfg, + apiURL: "https://api.telegram.org/bot" + cfg.Token, + log: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})).With(slog.String("platform", "telegram")), + } +} + +// Init initializes the Telegram platform +func (t *TelegramPlatform) Init(cfg *config.Config) error { + if t.config.Token == "" { + t.log.Error("Missing Telegram token") + return model.ErrPlatformInit + } + + // Set webhook URL based on hostname + webhookURL := fmt.Sprintf("https://%s/telegram/incoming/%s", cfg.Hostname, t.config.Token) + t.log.Info("Setting Telegram webhook", "url", webhookURL) + + // Create webhook setup request + url := fmt.Sprintf("%s/setWebhook", t.apiURL) + payload := map[string]interface{}{ + "url": webhookURL, + "max_connections": 40, + "allowed_updates": []string{"message"}, + } + + data, err := json.Marshal(payload) + if err != nil { + t.log.Error("Failed to marshal webhook payload", "error", err) + return fmt.Errorf("failed to marshal webhook payload: %w", err) + } + + resp, err := http.Post(url, "application/json", bytes.NewBuffer(data)) + if err != nil { + t.log.Error("Failed to set webhook", "error", err) + return fmt.Errorf("failed to set webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + errMsg := string(bodyBytes) + t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg) + return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg) + } + + t.log.Info("Telegram webhook successfully set") + return nil +} + +// ParseIncomingMessage parses an incoming Telegram message +func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) { + t.log.Debug("Parsing incoming Telegram message") + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + t.log.Error("Failed to read request body", "error", err) + return nil, err + } + defer r.Body.Close() + + // Parse JSON + var update struct { + Message struct { + MessageID int `json:"message_id"` + From struct { + ID int `json:"id"` + IsBot bool `json:"is_bot"` + Username string `json:"username"` + FirstName string `json:"first_name"` + } `json:"from"` + Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + } `json:"chat"` + Date int `json:"date"` + Text string `json:"text"` + } `json:"message"` + } + + if err := json.Unmarshal(body, &update); err != nil { + t.log.Error("Failed to unmarshal update", "error", err) + return nil, err + } + + // Convert to raw map for storage + var raw map[string]interface{} + if err := json.Unmarshal(body, &raw); err != nil { + t.log.Error("Failed to unmarshal raw data", "error", err) + return nil, err + } + + // Create message + msg := &model.Message{ + Text: update.Message.Text, + Chat: strconv.FormatInt(update.Message.Chat.ID, 10), + Author: update.Message.From.Username, + FromBot: update.Message.From.IsBot, + Date: time.Unix(int64(update.Message.Date), 0), + ID: strconv.Itoa(update.Message.MessageID), + Raw: raw, + } + + t.log.Debug("Parsed message", + "id", msg.ID, + "chat", msg.Chat, + "author", msg.Author, + "from_bot", msg.FromBot, + "text_length", len(msg.Text)) + + // Create Channel object + channelRaw, err := t.ParseChannelFromMessage(body) + if err != nil { + t.log.Error("Failed to parse channel data", "error", err) + return nil, err + } + + msg.Channel = &model.Channel{ + Platform: "telegram", + PlatformChannelID: msg.Chat, + ChannelRaw: channelRaw, + } + + return msg, nil +} + +// ParseChannelNameFromRaw extracts a human-readable channel name from raw data +func (t *TelegramPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string { + // Try to get the title first (for groups) + if chatInfo, ok := channelRaw["chat"].(map[string]interface{}); ok { + if title, ok := chatInfo["title"].(string); ok && title != "" { + return title + } + + // For private chats, use username + if username, ok := chatInfo["username"].(string); ok && username != "" { + return username + } + + // Fallback to first_name if available + if firstName, ok := chatInfo["first_name"].(string); ok && firstName != "" { + return firstName + } + + // Last resort: use the ID + if id, ok := chatInfo["id"].(float64); ok { + return strconv.FormatInt(int64(id), 10) + } + } + + return "unknown" +} + +// ParseChannelFromMessage extracts channel data from a message +func (t *TelegramPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) { + // Parse JSON to extract chat info + var update struct { + Message struct { + Chat map[string]any `json:"chat"` + } `json:"message"` + } + + if err := json.Unmarshal(body, &update); err != nil { + return nil, err + } + + if update.Message.Chat == nil { + return nil, errors.New("chat information not found") + } + + return map[string]any{ + "chat": update.Message.Chat, + }, nil +} + +// SendMessage sends a message to Telegram +func (t *TelegramPlatform) SendMessage(msg *model.Message) error { + // Convert chat ID to int64 + chatID, err := strconv.ParseInt(msg.Chat, 10, 64) + if err != nil { + t.log.Error("Failed to parse chat ID", "chat", msg.Chat, "error", err) + return err + } + + // Prepare payload + payload := map[string]interface{}{ + "chat_id": chatID, + "text": msg.Text, + } + + // Add reply if needed + if msg.ReplyTo != "" { + replyToID, err := strconv.Atoi(msg.ReplyTo) + if err == nil { + payload["reply_to_message_id"] = replyToID + } else { + t.log.Warn("Failed to parse reply_to ID", "reply_to", msg.ReplyTo, "error", err) + } + } + + t.log.Debug("Sending message to Telegram", "chat_id", chatID, "length", len(msg.Text)) + + // Convert payload to JSON + data, err := json.Marshal(payload) + if err != nil { + t.log.Error("Failed to marshal message payload", "error", err) + return err + } + + // Send HTTP request + resp, err := http.Post( + t.apiURL+"/sendMessage", + "application/json", + bytes.NewBuffer(data), + ) + if err != nil { + t.log.Error("Failed to send message", "error", err) + return err + } + defer resp.Body.Close() + + // Check response + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + errMsg := string(bodyBytes) + t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg) + return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg) + } + + t.log.Debug("Message sent successfully") + return nil +} \ No newline at end of file diff --git a/internal/plugin/fun/coin.go b/internal/plugin/fun/coin.go new file mode 100644 index 0000000..8e12a8d --- /dev/null +++ b/internal/plugin/fun/coin.go @@ -0,0 +1,50 @@ +package fun + +import ( + "math/rand" + "strings" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// CoinPlugin flips a coin +type CoinPlugin struct { + plugin.BasePlugin + rand *rand.Rand +} + +// NewCoin creates a new CoinPlugin instance +func NewCoin() *CoinPlugin { + source := rand.NewSource(time.Now().UnixNano()) + return &CoinPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "fun.coin", + Name: "Coin Flip", + Help: "Flips a coin when you type 'flip a coin'", + }, + rand: rand.New(source), + } +} + +// OnMessage handles incoming messages +func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") { + return nil + } + + result := "Heads" + if p.rand.Intn(2) == 0 { + result = "Tails" + } + + response := &model.Message{ + Text: result, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} diff --git a/internal/plugin/fun/dice.go b/internal/plugin/fun/dice.go new file mode 100644 index 0000000..00fc7cc --- /dev/null +++ b/internal/plugin/fun/dice.go @@ -0,0 +1,118 @@ +package fun + +import ( + "fmt" + "math/rand" + "regexp" + "strconv" + "strings" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// DicePlugin rolls dice based on standard dice notation +type DicePlugin struct { + plugin.BasePlugin + rand *rand.Rand +} + +// NewDice creates a new DicePlugin instance +func NewDice() *DicePlugin { + source := rand.NewSource(time.Now().UnixNano()) + return &DicePlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "fun.dice", + Name: "Dice Roller", + Help: "Rolls dice when you type '!dice [formula]' (default: 1d20)", + }, + rand: rand.New(source), + } +} + +// OnMessage handles incoming messages +func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") { + return nil + } + + // Extract dice formula + formula := strings.TrimSpace(strings.TrimPrefix(msg.Text, "!dice")) + formula = strings.TrimSpace(strings.TrimPrefix(formula, "!dice")) + + if formula == "" { + formula = "1d20" // Default formula + } + + // Parse and roll the dice + result, err := p.rollDice(formula) + responseText := "" + + if err != nil { + responseText = fmt.Sprintf("Error: %s", err.Error()) + } else { + responseText = fmt.Sprintf("%d", result) + } + + response := &model.Message{ + Text: responseText, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} + +// rollDice parses a dice formula string and returns the result +func (p *DicePlugin) rollDice(formula string) (int, error) { + // Support basic dice notation like "2d6", "1d20+5", etc. + diceRegex := regexp.MustCompile(`^(\d+)d(\d+)(?:([+-])(\d+))?$`) + matches := diceRegex.FindStringSubmatch(formula) + + if matches == nil { + return 0, fmt.Errorf("invalid dice formula: %s", formula) + } + + // Parse number of dice + numDice, err := strconv.Atoi(matches[1]) + if err != nil || numDice < 1 { + return 0, fmt.Errorf("invalid number of dice") + } + if numDice > 100 { + return 0, fmt.Errorf("too many dice (max 100)") + } + + // Parse number of sides + sides, err := strconv.Atoi(matches[2]) + if err != nil || sides < 1 { + return 0, fmt.Errorf("invalid number of sides") + } + if sides > 1000 { + return 0, fmt.Errorf("too many sides (max 1000)") + } + + // Roll the dice + total := 0 + for i := 0; i < numDice; i++ { + roll := p.rand.Intn(sides) + 1 + total += roll + } + + // Apply modifier if present + if len(matches) > 3 && matches[3] != "" { + modifier, err := strconv.Atoi(matches[4]) + if err != nil { + return 0, fmt.Errorf("invalid modifier") + } + + if matches[3] == "+" { + total += modifier + } else if matches[3] == "-" { + total -= modifier + } + } + + return total, nil +} diff --git a/internal/plugin/fun/loquito.go b/internal/plugin/fun/loquito.go new file mode 100644 index 0000000..7b0ea43 --- /dev/null +++ b/internal/plugin/fun/loquito.go @@ -0,0 +1,40 @@ +package fun + +import ( + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// LoquitoPlugin replies with "Loquito tu." when someone says "lo quito" +type LoquitoPlugin struct { + plugin.BasePlugin +} + +// NewLoquito creates a new LoquitoPlugin instance +func NewLoquito() *LoquitoPlugin { + return &LoquitoPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "fun.loquito", + Name: "Loquito Reply", + Help: "Replies with 'Loquito tu.' when someone says 'lo quito'", + }, + } +} + +// OnMessage handles incoming messages +func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + if !strings.Contains(strings.ToLower(msg.Text), "lo quito") { + return nil + } + + response := &model.Message{ + Text: "Loquito tu.", + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} diff --git a/internal/plugin/ping/ping.go b/internal/plugin/ping/ping.go new file mode 100644 index 0000000..b09caaf --- /dev/null +++ b/internal/plugin/ping/ping.go @@ -0,0 +1,40 @@ +package ping + +import ( + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// PingPlugin is a simple ping/pong plugin +type PingPlugin struct { + plugin.BasePlugin +} + +// New creates a new PingPlugin instance +func New() *PingPlugin { + return &PingPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "dev.ping", + Name: "Ping", + Help: "Responds to 'ping' with 'pong'", + }, + } +} + +// OnMessage handles incoming messages +func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") { + return nil + } + + response := &model.Message{ + Text: "pong", + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..69da2c2 --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,82 @@ +package plugin + +import ( + "sync" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +var ( + // plugins holds all registered plugins + plugins = make(map[string]model.Plugin) + + // pluginsMu protects the plugins map + pluginsMu sync.RWMutex +) + +// Register registers a plugin with the given ID +func Register(plugin model.Plugin) { + pluginsMu.Lock() + defer pluginsMu.Unlock() + plugins[plugin.GetID()] = plugin +} + +// Get returns a plugin by ID +func Get(id string) (model.Plugin, error) { + pluginsMu.RLock() + defer pluginsMu.RUnlock() + + plugin, exists := plugins[id] + if !exists { + return nil, model.ErrPluginNotFound + } + + return plugin, nil +} + +// GetAvailablePlugins returns all registered plugins +func GetAvailablePlugins() map[string]model.Plugin { + pluginsMu.RLock() + defer pluginsMu.RUnlock() + + // Create a copy to avoid race conditions + result := make(map[string]model.Plugin, len(plugins)) + for id, plugin := range plugins { + result[id] = plugin + } + + return result +} + +// BasePlugin provides a common base for plugins +type BasePlugin struct { + ID string + Name string + Help string + ConfigRequired bool +} + +// GetID returns the plugin ID +func (p *BasePlugin) GetID() string { + return p.ID +} + +// GetName returns the plugin name +func (p *BasePlugin) GetName() string { + return p.Name +} + +// GetHelp returns the plugin help text +func (p *BasePlugin) GetHelp() string { + return p.Help +} + +// RequiresConfig indicates if the plugin requires configuration +func (p *BasePlugin) RequiresConfig() bool { + return p.ConfigRequired +} + +// OnMessage is the default implementation that does nothing +func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + return nil +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go new file mode 100644 index 0000000..668bf60 --- /dev/null +++ b/internal/queue/queue.go @@ -0,0 +1,99 @@ +package queue + +import ( + "log/slog" + "sync" +) + +// Item represents a queue item +type Item struct { + Platform string + Request map[string]interface{} +} + +// HandlerFunc defines a function that processes queue items +type HandlerFunc func(item Item) + +// Queue represents a message queue +type Queue struct { + items chan Item + wg sync.WaitGroup + quit chan struct{} + logger *slog.Logger + running bool + runMutex sync.Mutex +} + +// New creates a new Queue instance +func New(logger *slog.Logger) *Queue { + return &Queue{ + items: make(chan Item, 100), + quit: make(chan struct{}), + logger: logger, + } +} + +// Start starts processing queue items +func (q *Queue) Start(handler HandlerFunc) { + q.runMutex.Lock() + defer q.runMutex.Unlock() + + if q.running { + return + } + + q.running = true + + // Start worker + q.wg.Add(1) + go q.worker(handler) +} + +// Stop stops processing queue items +func (q *Queue) Stop() { + q.runMutex.Lock() + defer q.runMutex.Unlock() + + if !q.running { + return + } + + q.running = false + close(q.quit) + q.wg.Wait() +} + +// Add adds an item to the queue +func (q *Queue) Add(item Item) { + select { + case q.items <- item: + // Item added successfully + default: + // Queue is full + q.logger.Info("Queue is full, dropping message") + } +} + +// worker processes queue items +func (q *Queue) worker(handler HandlerFunc) { + defer q.wg.Done() + + for { + select { + case item := <-q.items: + // Process item + func() { + defer func() { + if r := recover(); r != nil { + q.logger.Error("Panic in queue worker", "error", r) + } + }() + + handler(item) + }() + case <-q.quit: + // Quit worker + return + } + } +} \ No newline at end of file diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 35ed11d..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1327 +0,0 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. - -[[package]] -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.*" -files = [ - {file = "alembic-1.4.3-py2.py3-none-any.whl", hash = "sha256:4e02ed2aa796bd179965041afa092c55b51fb077de19d61835673cc80672c01c"}, - {file = "alembic-1.4.3.tar.gz", hash = "sha256:5334f32314fb2a56d86b4c4dd1ae34b08c03cae4cb888bc699942104d66bc245"}, -] - -[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" -optional = false -python-versions = "*" -files = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] - -[[package]] -name = "appnope" -version = "0.1.2" -description = "Disable App Nap on macOS >= 10.9" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"}, - {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"}, -] - -[[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.*" -files = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, -] - -[[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.*" -files = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, -] - -[package.extras] -dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "pre-commit", "pympler", "pytest (>=4.3.0)", "six", "sphinx", "zope.interface"] -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 = "*" -files = [ - {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, - {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, -] - -[[package]] -name = "banal" -version = "1.0.1" -description = "Commons of banal micro-functions for Python." -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "banal-1.0.1-py2.py3-none-any.whl", hash = "sha256:6431876cda7b8ff27b1a64fef3a4bdc1f3f54f8dff7beaa93241e01336a0e91d"}, - {file = "banal-1.0.1.tar.gz", hash = "sha256:5541e7c98ea04841f4ff2887bbc3f2dccf982549a99d01c0939aac250fffcf7a"}, -] - -[[package]] -name = "black" -version = "19.10b0" -description = "The uncompromising code formatter." -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, - {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, -] - -[package.dependencies] -appdirs = "*" -attrs = ">=18.1.0" -click = ">=6.5" -pathspec = ">=0.6,<1" -regex = "*" -toml = ">=0.9.4" -typed-ast = ">=1.4.0" - -[package.extras] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] - -[[package]] -name = "certifi" -version = "2022.12.7" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, -] - -[[package]] -name = "cfgv" -version = "3.2.0" -description = "Validate configuration and produce human readable error messages." -category = "dev" -optional = false -python-versions = ">=3.6.1" -files = [ - {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, - {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, -] - -[[package]] -name = "chardet" -version = "3.0.4" -description = "Universal encoding detector for Python 2 and 3" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, -] - -[[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.*" -files = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, -] - -[[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.*" -files = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, -] - -[[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" -files = [ - {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"}, -] - -[package.extras] -toml = ["toml"] - -[[package]] -name = "dataset" -version = "1.4.1" -description = "Toolkit for Python-based database access." -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "dataset-1.4.1-py2.py3-none-any.whl", hash = "sha256:b6b93662e4fb4240d4d68da4156b7b0713cd3d639455d9c2411636ad0ead59de"}, - {file = "dataset-1.4.1.tar.gz", hash = "sha256:97902a3d4d62a506c74904fa7c2512a982f12f9892786f2e77b2cdfbd9f72388"}, -] - -[package.dependencies] -alembic = ">=0.6.2" -banal = ">=1.0.1" -sqlalchemy = ">=1.3.2" - -[package.extras] -dev = ["PyMySQL", "coverage", "cryptography", "flake8", "nose", "pip", "psycopg2-binary", "wheel"] - -[[package]] -name = "decorator" -version = "4.4.2" -description = "Decorators for Humans" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*" -files = [ - {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, - {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, -] - -[[package]] -name = "dice" -version = "3.1.1" -description = "A library for parsing and evaluating dice notation" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "dice-3.1.1-py2.py3-none-any.whl", hash = "sha256:43c427532d64baefda5bb5d29a4bba1adad1f4b5cc2b8ec28dfcbb7228765385"}, - {file = "dice-3.1.1.tar.gz", hash = "sha256:99d9c3a90c4f0d016911526c1ea5a10394e047cc3ce61eab22fd34c0f4bc9f60"}, -] - -[package.dependencies] -docopt = ">=0.6.1" -pyparsing = ">=2.4.1" - -[[package]] -name = "distlib" -version = "0.3.1" -description = "Distribution utilities" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, -] - -[[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, -] - -[[package]] -name = "filelock" -version = "3.0.12" -description = "A platform independent file lock." -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, -] - -[[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" -files = [ - {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, - {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, -] - -[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]] -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.*" -files = [ - {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, - {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, -] - -[package.dependencies] -click = ">=5.1" -itsdangerous = ">=0.24" -Jinja2 = ">=2.10.1" -Werkzeug = ">=0.15" - -[package.extras] -dev = ["coverage", "pallets-sphinx-themes", "pytest", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet", "tox"] -docs = ["pallets-sphinx-themes", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet"] -dotenv = ["python-dotenv"] - -[[package]] -name = "identify" -version = "1.5.13" -description = "File identification library for Python" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -files = [ - {file = "identify-1.5.13-py2.py3-none-any.whl", hash = "sha256:9dfb63a2e871b807e3ba62f029813552a24b5289504f5b071dea9b041aee9fe4"}, - {file = "identify-1.5.13.tar.gz", hash = "sha256:70b638cf4743f33042bebb3b51e25261a0a10e80f978739f17e7fd4837664a66"}, -] - -[package.extras] -license = ["editdistance"] - -[[package]] -name = "idna" -version = "2.10" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, -] - -[[package]] -name = "importlib-metadata" -version = "3.1.1" -description = "Read metadata from Python packages" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "importlib_metadata-3.1.1-py3-none-any.whl", hash = "sha256:6112e21359ef8f344e7178aa5b72dc6e62b38b0d008e6d3cb212c5b84df72013"}, - {file = "importlib_metadata-3.1.1.tar.gz", hash = "sha256:b0c2d3b226157ae4517d9625decf63591461c66b3a808c2666d538946519d170"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] - -[[package]] -name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] - -[[package]] -name = "ipdb" -version = "0.13.4" -description = "IPython-enabled pdb" -category = "dev" -optional = false -python-versions = ">=2.7" -files = [ - {file = "ipdb-0.13.4.tar.gz", hash = "sha256:c85398b5fb82f82399fc38c44fe3532c0dde1754abee727d8f5cfcc74547b334"}, -] - -[package.dependencies] -ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} -setuptools = "*" - -[[package]] -name = "ipython" -version = "7.19.0" -description = "IPython: Productive Interactive Computing" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "ipython-7.19.0-py3-none-any.whl", hash = "sha256:c987e8178ced651532b3b1ff9965925bfd445c279239697052561a9ab806d28f"}, - {file = "ipython-7.19.0.tar.gz", hash = "sha256:cbb2ef3d5961d44e6a963b9817d4ea4e1fa2eb589c371a470fed14d8d40cbd6a"}, -] - -[package.dependencies] -appnope = {version = "*", markers = "sys_platform == \"darwin\""} -backcall = "*" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -decorator = "*" -jedi = ">=0.10" -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] -all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] -doc = ["Sphinx (>=1.3)"] -kernel = ["ipykernel"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["ipywidgets", "notebook"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["ipykernel", "nbformat", "nose (>=0.10.1)", "numpy (>=1.14)", "pygments", "requests", "testpath"] - -[[package]] -name = "ipython-genutils" -version = "0.2.0" -description = "Vestigial utilities from IPython" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, - {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, -] - -[[package]] -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.*" -files = [ - {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, - {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, -] - -[package.extras] -pipfile = ["pipreqs", "requirementslib"] -pyproject = ["toml"] -requirements = ["pip-api", "pipreqs"] -xdg-home = ["appdirs (>=1.4.0)"] - -[[package]] -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.*" -files = [ - {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, - {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, -] - -[[package]] -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.*" -files = [ - {file = "jedi-0.17.2-py2.py3-none-any.whl", hash = "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5"}, - {file = "jedi-0.17.2.tar.gz", hash = "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20"}, -] - -[package.dependencies] -parso = ">=0.7.0,<0.8.0" - -[package.extras] -qa = ["flake8 (==3.7.9)"] -testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] - -[[package]] -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.*" -files = [ - {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, - {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, -] - -[package.dependencies] -MarkupSafe = ">=0.23" - -[package.extras] -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" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "Mako-1.1.3-py2.py3-none-any.whl", hash = "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9"}, - {file = "Mako-1.1.3.tar.gz", hash = "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27"}, -] - -[package.dependencies] -MarkupSafe = ">=0.9.2" - -[package.extras] -babel = ["Babel"] -lingua = ["lingua"] - -[[package]] -name = "markupsafe" -version = "2.1.2" -description = "Safely add untrusted strings to HTML/XML markup." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, - {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, -] - -[[package]] -name = "mccabe" -version = "0.6.1" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] - -[[package]] -name = "nodeenv" -version = "1.5.0" -description = "Node.js virtual environment builder" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, - {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, -] - -[[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.*" -files = [ - {file = "packaging-20.7-py2.py3-none-any.whl", hash = "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"}, - {file = "packaging-20.7.tar.gz", hash = "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236"}, -] - -[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.*" -files = [ - {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"}, - {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"}, -] - -[package.extras] -testing = ["docopt", "pytest (>=3.0.7)"] - -[[package]] -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.*" -files = [ - {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, - {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, -] - -[[package]] -name = "pexpect" -version = "4.8.0" -description = "Pexpect allows easy control of interactive console applications." -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, - {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, -] - -[package.dependencies] -ptyprocess = ">=0.5" - -[[package]] -name = "pickleshare" -version = "0.7.5" -description = "Tiny 'shelve'-like database with concurrency support" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, - {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, -] - -[[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.*" -files = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, -] - -[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" -files = [ - {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"}, -] - -[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" -optional = false -python-versions = ">=3.6.1" -files = [ - {file = "prompt_toolkit-3.0.8-py3-none-any.whl", hash = "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63"}, - {file = "prompt_toolkit-3.0.8.tar.gz", hash = "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c"}, -] - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "ptyprocess" -version = "0.6.0" -description = "Run a subprocess in a pseudo terminal" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"}, - {file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"}, -] - -[[package]] -name = "py" -version = "1.9.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, - {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, -] - -[[package]] -name = "pycodestyle" -version = "2.6.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, - {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, -] - -[[package]] -name = "pyflakes" -version = "2.2.0" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, - {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, -] - -[[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" -files = [ - {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"}, - {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"}, -] - -[[package]] -name = "pyparsing" -version = "2.4.7" -description = "Python parsing module" -category = "main" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, -] - -[[package]] -name = "pytest" -version = "6.1.2" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ - {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, - {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, -] - -[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]] -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.*" -files = [ - {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"}, -] - -[package.dependencies] -coverage = ">=4.4" -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests (==2.0.2)", "pytest-xdist", "six", "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" -files = [ - {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"}, -] - -[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 = "*" -files = [ - {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-py3-none-any.whl", hash = "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d"}, -] - -[[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.*" -files = [ - {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-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, - {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-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, - {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-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, - {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-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, - {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"}, -] - -[[package]] -name = "regex" -version = "2020.11.13" -description = "Alternative regular expression module, to replace re." -category = "dev" -optional = false -python-versions = "*" -files = [ - {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"}, -] - -[[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.*" -files = [ - {file = "requests-2.25.0-py2.py3-none-any.whl", hash = "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"}, - {file = "requests-2.25.0.tar.gz", hash = "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" -idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.27" - -[package.extras] -security = ["cryptography (>=1.3.4)", "pyOpenSSL (>=0.14)"] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] - -[[package]] -name = "rope" -version = "0.16.0" -description = "a python refactoring library..." -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "rope-0.16.0-py2-none-any.whl", hash = "sha256:ae1fa2fd56f64f4cc9be46493ce54bed0dd12dee03980c61a4393d89d84029ad"}, - {file = "rope-0.16.0-py3-none-any.whl", hash = "sha256:52423a7eebb5306a6d63bdc91a7c657db51ac9babfb8341c9a1440831ecf3203"}, - {file = "rope-0.16.0.tar.gz", hash = "sha256:d2830142c2e046f5fc26a022fe680675b6f48f81c7fc1f03a950706e746e9dfe"}, -] - -[package.extras] -dev = ["pytest"] - -[[package]] -name = "setuptools" -version = "65.6.3" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"}, - {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -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.*" -files = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, -] - -[[package]] -name = "sqlalchemy" -version = "1.3.20" -description = "Database Abstraction Library" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {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"}, -] - -[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 = "*" -files = [ - {file = "structlog-20.1.0-py2.py3-none-any.whl", hash = "sha256:8a672be150547a93d90a7d74229a29e765be05bd156a35cdcc527ebf68e9af92"}, - {file = "structlog-20.1.0.tar.gz", hash = "sha256:7a48375db6274ed1d0ae6123c486472aa1d0890b08d314d2b016f3aa7f35990b"}, -] - -[package.dependencies] -six = "*" - -[package.extras] -azure-pipelines = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "pytest-azurepipelines", "python-rapidjson", "simplejson"] -dev = ["coverage[toml]", "freezegun (>=0.2.8)", "pre-commit", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson", "sphinx", "twisted"] -docs = ["sphinx", "twisted"] -tests = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "pytest-asyncio", "python-rapidjson", "simplejson"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - -[[package]] -name = "traitlets" -version = "5.0.5" -description = "Traitlets Python configuration system" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"}, - {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"}, -] - -[package.dependencies] -ipython-genutils = "*" - -[package.extras] -test = ["pytest"] - -[[package]] -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 = "*" -files = [ - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, - {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, - {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, - {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, - {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, - {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, - {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, - {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, - {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, - {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, - {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, -] - -[[package]] -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" -files = [ - {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, - {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, -] - -[package.extras] -brotli = ["brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] -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" -files = [ - {file = "virtualenv-20.4.2-py2.py3-none-any.whl", hash = "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"}, - {file = "virtualenv-20.4.2.tar.gz", hash = "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d"}, -] - -[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)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "xonsh (>=0.9.16)"] - -[[package]] -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" -files = [ - {file = "waitress-1.4.4-py2.py3-none-any.whl", hash = "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"}, - {file = "waitress-1.4.4.tar.gz", hash = "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261"}, -] - -[package.extras] -docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.9)"] -testing = ["coverage (>=5.0)", "pytest", "pytest-cover"] - -[[package]] -name = "wcwidth" -version = "0.2.5" -description = "Measures the displayed width of unicode strings in a terminal" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] - -[[package]] -name = "werkzeug" -version = "2.2.3" -description = "The comprehensive WSGI web application library." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612"}, - {file = "Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe"}, -] - -[package.dependencies] -MarkupSafe = ">=2.1.1" - -[package.extras] -watchdog = ["watchdog"] - -[[package]] -name = "zipp" -version = "3.4.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, - {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, -] - -[package.extras] -docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["func-timeout", "jaraco.itertools", "jaraco.test (>=3.2.0)", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.7" -content-hash = "e4014ee68179696ae11e98413b379d11d25c4169a7373a36218c2b085c95cc8f" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 1e485cd..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[tool.poetry] -name = "butterrobot" -version = "0.0.3" -description = "What is my purpose?" -authors = ["Felipe Martin "] -license = "GPL-2.0" -packages = [ - { include = "butterrobot" }, - { include = "butterrobot_plugins_contrib" }, -] -include = ["README.md"] -readme = "README.md" - -[tool.poetry.dependencies] -python = "^3.7" -structlog = "^20.1.0" -colorama = "^0.4.3" -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" -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"] -"fun.loquito" = "butterrobot_plugins_contrib.fun:LoquitoPlugin" -"fun.dice" = "butterrobot_plugins_contrib.fun:DicePlugin" -"fun.coin" = "butterrobot_plugins_contrib.fun:CoinPlugin" -"dev.ping" = "butterrobot_plugins_contrib.dev:PingPlugin" - -[build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 08f065e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,16 +0,0 @@ -[flake8] -ignore = E203, E266, E501, W503, F403 -max-line-length = 88 -max-complexity = 18 -select = B,C,E,F,W,T4,B9 - -[isort] -use_parentheses = True -multi_line_output = 3 -include_trailing_comma = True -length_sort = 1 -lines_between_types = 0 -line_length = 88 -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 deleted file mode 100644 index 38c9e2e..0000000 --- a/tests/test_db.py +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index ffab485..0000000 --- a/tests/test_objects.py +++ /dev/null @@ -1,18 +0,0 @@ -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") From 3426b668fecf04c3c6b307925ae4e15ed4843072 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:09:54 +0200 Subject: [PATCH 31/44] ci: fix deprecated attribute in goreleaser --- .goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 68d1f62..c89e189 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -47,7 +47,7 @@ archives: {{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "arm64" }}aarch64{{- else }}{{ .Arch }}{{ end }}_{{ .Version }} format_overrides: - goos: windows - format: zip + formats: ['zip'] dockers: - image_templates: From bbb48f49e2c569527e4aab17da4b8402468c6003 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:10:08 +0200 Subject: [PATCH 32/44] fix: embed templates directly --- internal/admin/admin.go | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 715c5e1..d590995 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -1,9 +1,9 @@ package admin import ( + "embed" "html/template" "net/http" - "path/filepath" "strconv" "strings" @@ -14,12 +14,12 @@ import ( "github.com/gorilla/sessions" ) +//go:embed templates/*.html +var templateFS embed.FS + const ( // Session store key sessionKey = "butterrobot-session" - - // Template directory - templateDir = "./internal/admin/templates" ) // FlashMessage represents a flash message @@ -63,9 +63,17 @@ func New(cfg *config.Config, database *db.Database) *Admin { "contains": strings.Contains, } + // Read base template from embedded filesystem + baseContent, err := templateFS.ReadFile("templates/_base.html") + if err != nil { + panic(err) + } + // Create a custom template with functions - baseTemplate := template.New("_base.html").Funcs(funcMap) - baseTemplate = template.Must(baseTemplate.ParseFiles(filepath.Join(templateDir, "_base.html"))) + baseTemplate, err := template.New("_base.html").Funcs(funcMap).Parse(string(baseContent)) + if err != nil { + panic(err) + } // Parse and register all templates templateFiles := []string{ @@ -78,11 +86,24 @@ func New(cfg *config.Config, database *db.Database) *Admin { } for _, tf := range templateFiles { - // Create a clone of the base template - t, err := template.Must(baseTemplate.Clone()).ParseFiles(filepath.Join(templateDir, tf)) + // Read template content from embedded filesystem + content, err := templateFS.ReadFile("templates/" + tf) if err != nil { panic(err) } + + // Create a clone of the base template + t, err := baseTemplate.Clone() + if err != nil { + panic(err) + } + + // Parse the template content + t, err = t.Parse(string(content)) + if err != nil { + panic(err) + } + templates[tf] = t } From 84e5feeb81a150cdeef7972335f6c930daa21bae Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:12:03 +0200 Subject: [PATCH 33/44] ci: limit goreleaser to two tasks per release --- .woodpecker/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index b24eb15..3630566 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -13,4 +13,4 @@ steps: - "/var/run/docker.sock:/var/run/docker.sock" commands: - docker login -u fmartingr -p $GITEA_TOKEN git.nakama.town - - goreleaser release --clean + - goreleaser release --clean --parallelism=2 From ece8280358f6a21f973b6cc5e8b7c3caf48f03fa Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:32:10 +0200 Subject: [PATCH 34/44] feat: db migrations, encrypted passwords --- README.md | 6 + cmd/butterrobot/main.go | 17 ++- docs/migrations.md | 99 +++++++++++++++ go.mod | 1 + go.sum | 2 + internal/db/db.go | 117 ++++++++--------- internal/migration/migration.go | 211 +++++++++++++++++++++++++++++++ internal/migration/migrations.go | 102 +++++++++++++++ 8 files changed, 490 insertions(+), 65 deletions(-) create mode 100644 docs/migrations.md create mode 100644 internal/migration/migration.go create mode 100644 internal/migration/migrations.go diff --git a/README.md b/README.md index 36ec708..214afa6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Go framework to create bots for several platforms. [Go to documentation](./docs) +### Database Management + +ButterRobot includes an automatic database migration system. Migrations are applied automatically when the application starts, ensuring your database schema is always up to date. + +[Learn more about migrations](./docs/migrations.md) + ## Installation ### From Source diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index 5cf57f9..2982a96 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "log/slog" "os" + "runtime/debug" "git.nakama.town/fmartingr/butterrobot/internal/app" "git.nakama.town/fmartingr/butterrobot/internal/config" @@ -19,15 +21,26 @@ func main() { os.Exit(1) } + // Handle version command + if len(os.Args) > 1 && os.Args[1] == "version" { + info, ok := debug.ReadBuildInfo() + if ok { + fmt.Printf("ButterRobot version %s\n", info.Main.Version) + } else { + fmt.Println("ButterRobot. Can't determine build information.") + } + return + } + // Initialize and run application application, err := app.New(cfg, logger) if err != nil { logger.Error("Failed to initialize application", "error", err) os.Exit(1) } - + if err := application.Run(); err != nil { logger.Error("Application error", "error", err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..65fcd99 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,99 @@ +# Database Migrations + +ButterRobot uses a simple database migration system to manage database schema changes. This document explains how the migration system works and how to extend it. + +## Automatic Migrations + +Migrations in ButterRobot are applied automatically when the application starts. This ensures your database schema is always up to date without requiring manual intervention. + +The migration system: +1. Checks which migrations have been applied +2. Applies any pending migrations in sequential order +3. Records each successful migration in the `schema_migrations` table + +## Initial State + +The initial migration (version 1) sets up the database with the following: + +- `channels` table for chat platforms +- `channel_plugin` table for plugins associated with channels +- `users` table for admin users with bcrypt password hashing +- Default admin user with username "admin" and password "admin" + +This migration represents the current state of the database schema. It is not backwards compatible with previous versions of ButterRobot. + +## Creating New Migrations + +To add a new migration, follow these steps: + +1. Open `/internal/migration/migrations.go` +2. Add a new migration version in the `init()` function: + +```go +Register(2, "Add example table", migrateAddExampleTableUp, migrateAddExampleTableDown) +``` + +3. Implement the up and down functions for your migration: + +```go +// Migration to add example table - version 2 +func migrateAddExampleTableUp(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS example ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + return err +} + +func migrateAddExampleTableDown(db *sql.DB) error { + _, err := db.Exec(`DROP TABLE IF EXISTS example`) + return err +} +``` + +## Migration Guidelines + +1. **Incremental Changes**: Each migration should make a small, focused change to the database schema. +2. **Backward Compatibility**: Ensure migrations are backward compatible with existing code when possible. +3. **Test Thoroughly**: Test both up and down migrations before deploying. +4. **Document Changes**: Add comments explaining the purpose of each migration. +5. **Version Numbers**: Use sequential version numbers for migrations. + +## How Migrations Work + +The migration system tracks applied migrations in a `schema_migrations` table. When you run migrations, the system: + +1. Checks which migrations have been applied +2. Applies any pending migrations in order +3. Records each successful migration in the `schema_migrations` table + +When rolling back, it performs the down migrations in reverse order. + +## In Code Usage + +The application automatically runs pending migrations when starting up. This is done in the `initDatabase` function. + +You can also programmatically work with migrations: + +```go +// Get database instance +database, err := db.New(cfg.DatabasePath) +if err != nil { + // Handle error +} +defer database.Close() + +// Run migrations +if err := database.MigrateUp(); err != nil { + // Handle error +} + +// Check migration status +applied, pending, err := database.MigrationStatus() +if err != nil { + // Handle error +} +``` \ No newline at end of file diff --git a/go.mod b/go.mod index ab85fc8..3f17f0d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24 require ( github.com/gorilla/sessions v1.4.0 + golang.org/x/crypto v0.37.0 modernc.org/sqlite v1.37.0 ) diff --git a/go.sum b/go.sum index 248cd40..f331cb5 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= diff --git a/internal/db/db.go b/internal/db/db.go index e288bb3..e1c51e0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,14 +1,15 @@ package db import ( - "crypto/sha256" "database/sql" - "encoding/hex" "encoding/json" "errors" + "fmt" + "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" + "git.nakama.town/fmartingr/butterrobot/internal/migration" "git.nakama.town/fmartingr/butterrobot/internal/model" ) @@ -505,7 +506,10 @@ func (d *Database) GetUserByID(id int64) (*model.User, error) { // CreateUser creates a new user func (d *Database) CreateUser(username, password string) (*model.User, error) { // Hash password - hashedPassword := hashPassword(password) + hashedPassword, err := hashPassword(password) + if err != nil { + return nil, err + } // Insert user query := ` @@ -555,9 +559,9 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err return nil, err } - // Check password - hashedPassword := hashPassword(password) - if dbPassword != hashedPassword { + // Check password with bcrypt + err = bcrypt.CompareHashAndPassword([]byte(dbPassword), []byte(password)) + if err != nil { return nil, errors.New("invalid credentials") } @@ -569,73 +573,60 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err } // Helper function to hash password -func hashPassword(password string) string { - // In a real implementation, use a proper password hashing library like bcrypt - // This is a simplified version for demonstration - hasher := sha256.New() - hasher.Write([]byte(password)) - return hex.EncodeToString(hasher.Sum(nil)) +func hashPassword(password string) (string, error) { + // Use bcrypt for secure password hashing + // The cost parameter is the computational cost, higher is more secure but slower + // Recommended minimum is 12 + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", err + } + return string(hashedBytes), nil } // Initialize database tables func initDatabase(db *sql.DB) error { - // Create channels table - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform TEXT NOT NULL, - platform_channel_id TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT 0, - channel_raw TEXT NOT NULL, - UNIQUE(platform, platform_channel_id) - ) - `) - if err != nil { - return err + // Ensure migration table exists + if err := migration.EnsureMigrationTable(db); err != nil { + return fmt.Errorf("failed to create migration table: %w", err) } - - // Create channel_plugin table - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS channel_plugin ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL, - plugin_id TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', - UNIQUE(channel_id, plugin_id), - FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE - ) - `) + + // Get applied migrations + applied, err := migration.GetAppliedMigrations(db) if err != nil { - return err + return fmt.Errorf("failed to get applied migrations: %w", err) } - - // Create users table - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL - ) - `) - if err != nil { - return err + + // Get all migration versions + allMigrations := make([]int, 0, len(migration.Migrations)) + for version := range migration.Migrations { + allMigrations = append(allMigrations, version) } - - // Create default admin user if it doesn't exist - var count int - err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) - if err != nil { - return err + + // Create a map of applied migrations for quick lookup + appliedMap := make(map[int]bool) + for _, version := range applied { + appliedMap[version] = true } - - if count == 0 { - hashedPassword := hashPassword("admin") - _, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", "admin", hashedPassword) - if err != nil { - return err + + // Count pending migrations + pendingCount := 0 + for _, version := range allMigrations { + if !appliedMap[version] { + pendingCount++ } } - + + // Run migrations if needed + if pendingCount > 0 { + fmt.Printf("Running %d pending database migrations...\n", pendingCount) + if err := migration.Migrate(db); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + fmt.Println("Database migrations completed successfully.") + } else { + fmt.Println("Database schema is up to date.") + } + return nil } diff --git a/internal/migration/migration.go b/internal/migration/migration.go new file mode 100644 index 0000000..44096f3 --- /dev/null +++ b/internal/migration/migration.go @@ -0,0 +1,211 @@ +package migration + +import ( + "database/sql" + "fmt" + "sort" + "time" +) + +// Migration represents a database migration +type Migration struct { + Version int + Description string + Up func(db *sql.DB) error + Down func(db *sql.DB) error +} + +// Migrations is a collection of registered migrations +var Migrations = make(map[int]Migration) + +// Register adds a migration to the list of available migrations +func Register(version int, description string, up, down func(db *sql.DB) error) { + if _, exists := Migrations[version]; exists { + panic(fmt.Sprintf("migration version %d already exists", version)) + } + + Migrations[version] = Migration{ + Version: version, + Description: description, + Up: up, + Down: down, + } +} + +// EnsureMigrationTable creates the migration table if it doesn't exist +func EnsureMigrationTable(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP NOT NULL + ) + `) + return err +} + +// GetAppliedMigrations returns a list of applied migration versions +func GetAppliedMigrations(db *sql.DB) ([]int, error) { + rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") + if err != nil { + return nil, err + } + defer rows.Close() + + var versions []int + for rows.Next() { + var version int + if err := rows.Scan(&version); err != nil { + return nil, err + } + versions = append(versions, version) + } + + return versions, rows.Err() +} + +// IsApplied checks if a migration version has been applied +func IsApplied(db *sql.DB, version int) (bool, error) { + var count int + err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// MarkAsApplied marks a migration as applied +func MarkAsApplied(db *sql.DB, version int) error { + _, err := db.Exec( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + version, time.Now(), + ) + return err +} + +// RemoveApplied removes a migration from the applied list +func RemoveApplied(db *sql.DB, version int) error { + _, err := db.Exec("DELETE FROM schema_migrations WHERE version = ?", version) + return err +} + +// Migrate runs pending migrations up to the latest version +func Migrate(db *sql.DB) error { + // Ensure migration table exists + if err := EnsureMigrationTable(db); err != nil { + return fmt.Errorf("failed to create migration table: %w", err) + } + + // Get applied migrations + applied, err := GetAppliedMigrations(db) + if err != nil { + return fmt.Errorf("failed to get applied migrations: %w", err) + } + + // Create a map of applied migrations for quick lookup + appliedMap := make(map[int]bool) + for _, version := range applied { + appliedMap[version] = true + } + + // Get all migration versions and sort them + var versions []int + for version := range Migrations { + versions = append(versions, version) + } + sort.Ints(versions) + + // Apply each pending migration + for _, version := range versions { + if !appliedMap[version] { + migration := Migrations[version] + fmt.Printf("Applying migration %d: %s...\n", version, migration.Description) + + // Start transaction for the migration + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction for migration %d: %w", version, err) + } + + // Apply the migration + if err := migration.Up(db); err != nil { + tx.Rollback() + return fmt.Errorf("failed to apply migration %d: %w", version, err) + } + + // Mark as applied + if _, err := tx.Exec( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + version, time.Now(), + ); err != nil { + tx.Rollback() + return fmt.Errorf("failed to mark migration %d as applied: %w", version, err) + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration %d: %w", version, err) + } + + fmt.Printf("Migration %d applied successfully\n", version) + } + } + + return nil +} + +// MigrateDown rolls back migrations down to the specified version +// If version is -1, it will roll back all migrations +func MigrateDown(db *sql.DB, targetVersion int) error { + // Ensure migration table exists + if err := EnsureMigrationTable(db); err != nil { + return fmt.Errorf("failed to create migration table: %w", err) + } + + // Get applied migrations + applied, err := GetAppliedMigrations(db) + if err != nil { + return fmt.Errorf("failed to get applied migrations: %w", err) + } + + // Sort in descending order to roll back newest first + sort.Sort(sort.Reverse(sort.IntSlice(applied))) + + // Roll back each migration until target version + for _, version := range applied { + if targetVersion == -1 || version > targetVersion { + migration, exists := Migrations[version] + if !exists { + return fmt.Errorf("migration %d is applied but not found in codebase", version) + } + + fmt.Printf("Rolling back migration %d: %s...\n", version, migration.Description) + + // Start transaction for the rollback + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction for rollback %d: %w", version, err) + } + + // Apply the down migration + if err := migration.Down(db); err != nil { + tx.Rollback() + return fmt.Errorf("failed to roll back migration %d: %w", version, err) + } + + // Remove from applied list + if _, err := tx.Exec("DELETE FROM schema_migrations WHERE version = ?", version); err != nil { + tx.Rollback() + return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err) + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit rollback %d: %w", version, err) + } + + fmt.Printf("Migration %d rolled back successfully\n", version) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go new file mode 100644 index 0000000..2852113 --- /dev/null +++ b/internal/migration/migrations.go @@ -0,0 +1,102 @@ +package migration + +import ( + "database/sql" + "golang.org/x/crypto/bcrypt" +) + +func init() { + // Register migrations + Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) +} + +// Initial schema creation with bcrypt passwords - version 1 +func migrateInitialSchemaUp(db *sql.DB) error { + // Create channels table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_channel_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + channel_raw TEXT NOT NULL, + UNIQUE(platform, platform_channel_id) + ) + `) + if err != nil { + return err + } + + // Create channel_plugin table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS channel_plugin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + UNIQUE(channel_id, plugin_id), + FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE + ) + `) + if err != nil { + return err + } + + // Create users table with bcrypt passwords + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL + ) + `) + if err != nil { + return err + } + + // Create default admin user with bcrypt password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), 12) + if err != nil { + return err + } + + // Check if users table is empty before inserting + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if err != nil { + return err + } + + if count == 0 { + _, err = db.Exec( + "INSERT INTO users (username, password) VALUES (?, ?)", + "admin", string(hashedPassword), + ) + if err != nil { + return err + } + } + + return nil +} + +func migrateInitialSchemaDown(db *sql.DB) error { + // Drop tables in reverse order of dependencies + _, err := db.Exec(`DROP TABLE IF EXISTS channel_plugin`) + if err != nil { + return err + } + + _, err = db.Exec(`DROP TABLE IF EXISTS channels`) + if err != nil { + return err + } + + _, err = db.Exec(`DROP TABLE IF EXISTS users`) + if err != nil { + return err + } + + return nil +} \ No newline at end of file From 6aedfc794f5d0f3097775de97921f0bb50ee22b5 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:44:45 +0200 Subject: [PATCH 35/44] feat: allow password change --- internal/admin/admin.go | 158 ++++++++++++++++-- internal/admin/templates/_base.html | 18 +- internal/admin/templates/change_password.html | 30 ++++ internal/db/db.go | 19 +++ 4 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 internal/admin/templates/change_password.html diff --git a/internal/admin/admin.go b/internal/admin/admin.go index d590995..045d980 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -2,6 +2,8 @@ package admin import ( "embed" + "encoding/gob" + "fmt" "html/template" "net/http" "strconv" @@ -28,6 +30,11 @@ type FlashMessage struct { Message string } +func init() { + // Register the FlashMessage type with gob package for session serialization + gob.Register(FlashMessage{}) +} + // TemplateData holds data for rendering templates type TemplateData struct { User *model.User @@ -52,8 +59,13 @@ type Admin struct { // New creates a new Admin instance func New(cfg *config.Config, database *db.Database) *Admin { - // Create session store + // Create session store with appropriate options store := sessions.NewCookieStore([]byte(cfg.SecretKey)) + store.Options = &sessions.Options{ + Path: "/admin", + MaxAge: 3600 * 24 * 7, // 1 week + HttpOnly: true, + } // Load templates templates := make(map[string]*template.Template) @@ -79,6 +91,7 @@ func New(cfg *config.Config, database *db.Database) *Admin { templateFiles := []string{ "index.html", "login.html", + "change_password.html", "channel_list.html", "channel_detail.html", "plugin_list.html", @@ -122,6 +135,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/", a.handleIndex) mux.HandleFunc("/admin/login", a.handleLogin) mux.HandleFunc("/admin/logout", a.handleLogout) + mux.HandleFunc("/admin/change-password", a.handleChangePassword) mux.HandleFunc("/admin/plugins", a.handlePluginList) mux.HandleFunc("/admin/channels", a.handleChannelList) mux.HandleFunc("/admin/channels/", a.handleChannelDetail) @@ -131,7 +145,11 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) { // getCurrentUser gets the current user from the session func (a *Admin) getCurrentUser(r *http.Request) *model.User { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for user retrieval: %v\n", err) + return nil + } // Check if user is logged in userID, ok := session.Values["user_id"].(int64) @@ -142,6 +160,7 @@ func (a *Admin) getCurrentUser(r *http.Request) *model.User { // Get user from database user, err := a.db.GetUserByID(userID) if err != nil { + fmt.Printf("Error retrieving user from database: %v\n", err) return nil } @@ -150,32 +169,63 @@ func (a *Admin) getCurrentUser(r *http.Request) *model.User { // isLoggedIn checks if the user is logged in func (a *Admin) isLoggedIn(r *http.Request) bool { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for login check: %v\n", err) + return false + } return session.Values["logged_in"] == true } // addFlash adds a flash message to the session func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + // If there's an error getting the session, create a new one + session = sessions.NewSession(a.store, sessionKey) + session.Options = &sessions.Options{ + Path: "/admin", + MaxAge: 3600 * 24 * 7, // 1 week + HttpOnly: true, + } + } - // Add flash message - flashes := session.Flashes() - if flashes == nil { - flashes = make([]interface{}, 0) + // Map internal categories to Bootstrap alert classes + alertClass := category + switch category { + case "success": + alertClass = "success" + case "danger": + alertClass = "danger" + case "warning": + alertClass = "warning" + case "info": + alertClass = "info" + default: + alertClass = "info" } flash := FlashMessage{ - Category: category, + Category: alertClass, Message: message, } session.AddFlash(flash) - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + // Log the error or handle it appropriately + fmt.Printf("Error saving session: %v\n", err) + } } // getFlashes gets all flash messages from the session func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + // If there's an error getting the session, return an empty slice + fmt.Printf("Error getting session for flashes: %v\n", err) + return []FlashMessage{} + } // Get flash messages flashes := session.Flashes() @@ -188,7 +238,10 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag } // Save session to clear flashes - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session after getting flashes: %v\n", err) + } return messages } @@ -299,10 +352,19 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // handleLogout handles the logout route func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { // Clear session - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for logout: %v\n", err) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + session.Values = make(map[interface{}]interface{}) session.Options.MaxAge = -1 // Delete session - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session for logout: %v\n", err) + } a.addFlash(w, r, "You were logged out", "success") @@ -310,6 +372,74 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) } +// handleChangePassword handles the change password route +func (a *Admin) handleChangePassword(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Get current user + user := a.getCurrentUser(r) + if user == nil { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Get form values + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + // Validate current password + _, err := a.db.CheckCredentials(user.Username, currentPassword) + if err != nil { + a.addFlash(w, r, "Current password is incorrect", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Validate new password and confirmation + if newPassword == "" { + a.addFlash(w, r, "New password cannot be empty", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + if newPassword != confirmPassword { + a.addFlash(w, r, "New passwords do not match", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Update password + if err := a.db.UpdateUserPassword(user.ID, newPassword); err != nil { + a.addFlash(w, r, "Failed to update password: "+err.Error(), "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Success + a.addFlash(w, r, "Password changed successfully", "success") + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + + // Render change password template + a.render(w, r, "change_password.html", TemplateData{ + Title: "Change Password", + }) +} + // handlePluginList handles the plugin list route func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) { // Check if user is logged in diff --git a/internal/admin/templates/_base.html b/internal/admin/templates/_base.html index d056ab5..4a414e3 100644 --- a/internal/admin/templates/_base.html +++ b/internal/admin/templates/_base.html @@ -28,8 +28,10 @@ Log in {{else}}
-
{{.User.Username}} - Log out
+
{{.User.Username}} - + Change Password | + Log out +
{{end}} @@ -100,14 +102,14 @@ {{end}} - {{range .Flash}} -
-
-
-

{{.Message}}

+
+ {{range .Flash}} + + {{end}}
- {{end}}
diff --git a/internal/admin/templates/change_password.html b/internal/admin/templates/change_password.html new file mode 100644 index 0000000..eed3dc5 --- /dev/null +++ b/internal/admin/templates/change_password.html @@ -0,0 +1,30 @@ +{{define "content"}} +
+
+
+
+

Change Password

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index e1c51e0..8cdce4a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -572,6 +572,25 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err }, nil } +// UpdateUserPassword updates a user's password +func (d *Database) UpdateUserPassword(userID int64, newPassword string) error { + // Hash the new password + hashedPassword, err := hashPassword(newPassword) + if err != nil { + return err + } + + // Update the user's password + query := ` + UPDATE users + SET password = ? + WHERE id = ? + ` + + _, err = d.db.Exec(query, hashedPassword, userID) + return err +} + // Helper function to hash password func hashPassword(password string) (string, error) { // Use bcrypt for secure password hashing From e0ae0c2a0b5aba03c5f08bf52e96081479113c37 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:50:28 +0200 Subject: [PATCH 36/44] fix: missing ca-certs --- .goreleaser.yml | 2 +- cmd/butterrobot/main.go | 2 ++ go.mod | 1 + go.sum | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index c89e189..a3836e9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -93,7 +93,7 @@ docker_manifests: nfpms: - maintainer: Felipe Martin - description: SMTP server to forward messages to shoutrrr endpoints + description: A chatbot server with customizable commands and triggers homepage: https://git.nakama.town/fmartingr/butterrobot license: AGPL-3.0 formats: diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index 2982a96..3bc56cb 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -8,6 +8,8 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/app" "git.nakama.town/fmartingr/butterrobot/internal/config" + + _ "golang.org/x/crypto/x509roots/fallback" ) func main() { diff --git a/go.mod b/go.mod index 3f17f0d..cd1bee5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24 require ( github.com/gorilla/sessions v1.4.0 golang.org/x/crypto v0.37.0 + golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df modernc.org/sqlite v1.37.0 ) diff --git a/go.sum b/go.sum index f331cb5..00c4a3c 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df h1:SwgTucX8ajPE0La2ELpYOIs8jVMoCMpAvYB6mDqP9vk= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= From c920eb94a035913bf4f5dc98fe6ee26ff0c3f66f Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 18:03:07 +0200 Subject: [PATCH 37/44] feat: added twitter and instagram link expanders --- internal/app/app.go | 3 ++ internal/plugin/social/instagram.go | 76 +++++++++++++++++++++++++++ internal/plugin/social/twitter.go | 79 +++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 internal/plugin/social/instagram.go create mode 100644 internal/plugin/social/twitter.go diff --git a/internal/app/app.go b/internal/app/app.go index 8d4ffcd..2e15bf6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/social" "git.nakama.town/fmartingr/butterrobot/internal/queue" ) @@ -72,6 +73,8 @@ func (a *App) Run() error { plugin.Register(fun.NewCoin()) plugin.Register(fun.NewDice()) plugin.Register(fun.NewLoquito()) + plugin.Register(social.NewTwitterExpander()) + plugin.Register(social.NewInstagramExpander()) // Initialize routes a.initializeRoutes() diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go new file mode 100644 index 0000000..a4f758a --- /dev/null +++ b/internal/plugin/social/instagram.go @@ -0,0 +1,76 @@ +package social + +import ( + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// InstagramExpander transforms instagram.com links to ddinstagram.com links +type InstagramExpander struct { + plugin.BasePlugin +} + +// New creates a new InstagramExpander instance +func NewInstagramExpander() *InstagramExpander { + return &InstagramExpander{ + BasePlugin: plugin.BasePlugin{ + ID: "social.instagram", + Name: "Instagram Link Expander", + Help: "Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters", + }, + } +} + +// OnMessage handles incoming messages +func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Skip empty messages + if strings.TrimSpace(msg.Text) == "" { + return nil + } + + // Regex to match instagram.com links + // Match both http://instagram.com and https://instagram.com formats + // Also match www.instagram.com + instagramRegex := regexp.MustCompile(`https?://(www\.)?(instagram\.com)/[^\s]+`) + + // Check if the message contains an Instagram link + if !instagramRegex.MatchString(msg.Text) { + return nil + } + + // Replace instagram.com with ddinstagram.com in the message and clean query parameters + transformed := instagramRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { + // Parse the URL + parsedURL, err := url.Parse(link) + if err != nil { + // If parsing fails, just do the simple replacement + link = strings.Replace(link, "instagram.com", "ddinstagram.com", 1) + return link + } + + // Change the host + if strings.Contains(parsedURL.Host, "instagram.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) + } + + // Remove query parameters + parsedURL.RawQuery = "" + + // Return the cleaned URL + return parsedURL.String() + }) + + // Create response message + response := &model.Message{ + Text: transformed, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go new file mode 100644 index 0000000..837b6c9 --- /dev/null +++ b/internal/plugin/social/twitter.go @@ -0,0 +1,79 @@ +package social + +import ( + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// TwitterExpander transforms twitter.com links to fxtwitter.com links +type TwitterExpander struct { + plugin.BasePlugin +} + +// New creates a new TwitterExpander instance +func NewTwitterExpander() *TwitterExpander { + return &TwitterExpander{ + BasePlugin: plugin.BasePlugin{ + ID: "social.twitter", + Name: "Twitter Link Expander", + Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", + }, + } +} + +// OnMessage handles incoming messages +func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Skip empty messages + if strings.TrimSpace(msg.Text) == "" { + return nil + } + + // Regex to match twitter.com links + // Match both http://twitter.com and https://twitter.com formats + // Also match www.twitter.com + twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`) + + // Check if the message contains a Twitter link + if !twitterRegex.MatchString(msg.Text) { + return nil + } + + // Replace twitter.com with fxtwitter.com in the message and clean query parameters + transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { + // Parse the URL + parsedURL, err := url.Parse(link) + if err != nil { + // If parsing fails, just do the simple replacement + link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1) + link = strings.Replace(link, "x.com", "fxtwitter.com", 1) + return link + } + + // Change the host + if strings.Contains(parsedURL.Host, "twitter.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) + } else if strings.Contains(parsedURL.Host, "x.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1) + } + + // Remove query parameters + parsedURL.RawQuery = "" + + // Return the cleaned URL + return parsedURL.String() + }) + + // Create response message + response := &model.Message{ + Text: transformed, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} From a0f12efd65c0d1999cbeaf39b47727a284c02f01 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 18:08:40 +0200 Subject: [PATCH 38/44] feat: show version in admin page --- internal/admin/admin.go | 6 ++++- internal/admin/templates/_base.html | 13 +++++++++++ internal/app/app.go | 36 ++++++++++++++++++----------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 045d980..822495a 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -46,6 +46,7 @@ type TemplateData struct { Channels []*model.Channel Channel *model.Channel ChannelPlugin *model.ChannelPlugin + Version string } // Admin represents the admin interface @@ -55,10 +56,11 @@ type Admin struct { store *sessions.CookieStore templates map[string]*template.Template baseTemplate *template.Template + version string } // New creates a new Admin instance -func New(cfg *config.Config, database *db.Database) *Admin { +func New(cfg *config.Config, database *db.Database, version string) *Admin { // Create session store with appropriate options store := sessions.NewCookieStore([]byte(cfg.SecretKey)) store.Options = &sessions.Options{ @@ -126,6 +128,7 @@ func New(cfg *config.Config, database *db.Database) *Admin { store: store, templates: templates, baseTemplate: baseTemplate, + version: version, } } @@ -264,6 +267,7 @@ func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName stri data.LoggedIn = a.isLoggedIn(r) data.Path = r.URL.Path data.Flash = a.getFlashes(w, r) + data.Version = a.version // Get template tmpl, ok := a.templates[templateName] diff --git a/internal/admin/templates/_base.html b/internal/admin/templates/_base.html index 4a414e3..3ebdf85 100644 --- a/internal/admin/templates/_base.html +++ b/internal/admin/templates/_base.html @@ -117,6 +117,19 @@
+
+
+
+
+
    +
  • + ButterRobot {{if .Version}}v{{.Version}}{{else}}(development){{end}} +
  • +
+
+
+
+
diff --git a/internal/app/app.go b/internal/app/app.go index 2e15bf6..5126672 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "runtime/debug" "strings" "syscall" "time" @@ -26,12 +27,13 @@ import ( // App represents the application type App struct { - config *config.Config - logger *slog.Logger - db *db.Database - router *http.ServeMux - queue *queue.Queue - admin *admin.Admin + config *config.Config + logger *slog.Logger + db *db.Database + router *http.ServeMux + queue *queue.Queue + admin *admin.Admin + version string } // New creates a new App instance @@ -48,16 +50,24 @@ func New(cfg *config.Config, logger *slog.Logger) (*App, error) { // Initialize message queue messageQueue := queue.New(logger) + // Get version information + version := "" + info, ok := debug.ReadBuildInfo() + if ok { + version = info.Main.Version + } + // Initialize admin interface - adminInterface := admin.New(cfg, database) + adminInterface := admin.New(cfg, database, version) return &App{ - config: cfg, - logger: logger, - db: database, - router: router, - queue: messageQueue, - admin: adminInterface, + config: cfg, + logger: logger, + db: database, + router: router, + queue: messageQueue, + admin: adminInterface, + version: version, }, nil } From 21e4c434fd8ce549ee128ab2b1d1795a4a6007ef Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 18:10:30 +0200 Subject: [PATCH 39/44] docs: updated plugin docs --- docs/creating-a-plugin.md | 105 +++++++++++++++++++++++++++++++++++++- docs/plugins.md | 5 ++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md index 945d03c..469491a 100644 --- a/docs/creating-a-plugin.md +++ b/docs/creating-a-plugin.md @@ -1,6 +1,18 @@ # Creating a Plugin -## Example +## Plugin Categories + +ButterRobot organizes plugins into different categories: + +- **Development**: Utility plugins like `ping` +- **Fun**: Entertainment plugins like dice rolling, coin flipping +- **Social**: Social media related plugins like URL transformers/expanders + +When creating a new plugin, consider which category it fits into and place it in the appropriate directory. + +## Plugin Examples + +### Basic Example: Marco Polo This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_: @@ -47,6 +59,92 @@ func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{ } ``` +### Advanced Example: URL Transformer + +This more complex plugin transforms URLs, useful for improving media embedding in chat platforms: + +```go +package social + +import ( + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// TwitterExpander transforms twitter.com links to fxtwitter.com links +type TwitterExpander struct { + plugin.BasePlugin +} + +// New creates a new TwitterExpander instance +func NewTwitter() *TwitterExpander { + return &TwitterExpander{ + BasePlugin: plugin.BasePlugin{ + ID: "social.twitter", + Name: "Twitter Link Expander", + Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", + }, + } +} + +// OnMessage handles incoming messages +func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Skip empty messages + if strings.TrimSpace(msg.Text) == "" { + return nil + } + + // Regex to match twitter.com links + twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`) + + // Check if the message contains a Twitter link + if !twitterRegex.MatchString(msg.Text) { + return nil + } + + // Transform the URL + transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { + // Parse the URL + parsedURL, err := url.Parse(link) + if err != nil { + // If parsing fails, just do the simple replacement + link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1) + link = strings.Replace(link, "x.com", "fxtwitter.com", 1) + return link + } + + // Change the host + if strings.Contains(parsedURL.Host, "twitter.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) + } else if strings.Contains(parsedURL.Host, "x.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1) + } + + // Remove query parameters + parsedURL.RawQuery = "" + + // Return the cleaned URL + return parsedURL.String() + }) + + // Create response message + response := &model.Message{ + Text: transformed, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} +``` + +## Registering Plugins + To use the plugin, register it in your application: ```go @@ -55,7 +153,10 @@ func (a *App) Run() error { // ... // Register plugins - plugin.Register(myplugin.New()) + plugin.Register(ping.New()) // Development plugin + plugin.Register(fun.NewCoin()) // Fun plugin + plugin.Register(social.NewTwitter()) // Social media plugin + plugin.Register(myplugin.New()) // Your custom plugin // ... } diff --git a/docs/plugins.md b/docs/plugins.md index 11e3d16..2988f80 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,3 +9,8 @@ - Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) - Dice: Put `!dice` and wathever roll you want to perform. - Coin: Flip a coin and get heads or tails. + +### Social Media + +- Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. +- Instagram Link Expander: Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters. This allows for better media embedding in chat platforms. From 72c6dd69823b680e1201b990e60215ce9205fd33 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:29:39 +0200 Subject: [PATCH 40/44] feat: remindme plugin --- docs/plugins.md | 4 + internal/admin/admin.go | 8 +- internal/app/app.go | 79 ++++++++++ internal/db/db.go | 127 ++++++++++++++- internal/migration/migration.go | 2 +- internal/migration/migrations.go | 32 +++- internal/model/message.go | 53 ++++--- internal/model/plugin.go | 10 +- internal/platform/telegram/telegram.go | 10 +- internal/plugin/reminder/reminder.go | 178 ++++++++++++++++++++++ internal/plugin/reminder/reminder_test.go | 164 ++++++++++++++++++++ internal/queue/queue.go | 76 ++++++++- 12 files changed, 695 insertions(+), 48 deletions(-) create mode 100644 internal/plugin/reminder/reminder.go create mode 100644 internal/plugin/reminder/reminder_test.go diff --git a/docs/plugins.md b/docs/plugins.md index 2988f80..84578e5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -10,6 +10,10 @@ - Dice: Put `!dice` and wathever roll you want to perform. - Coin: Flip a coin and get heads or tails. +### Utility + +- Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. + ### Social Media - Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 822495a..c2a78ca 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -106,19 +106,19 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { if err != nil { panic(err) } - + // Create a clone of the base template t, err := baseTemplate.Clone() if err != nil { panic(err) } - + // Parse the template content t, err = t.Parse(string(content)) if err != nil { panic(err) } - + templates[tf] = t } @@ -362,7 +362,7 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } - + session.Values = make(map[interface{}]interface{}) session.Options.MaxAge = -1 // Delete session err = session.Save(r, w) diff --git a/internal/app/app.go b/internal/app/app.go index 5126672..1d878ab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,10 +17,12 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/admin" "git.nakama.town/fmartingr/butterrobot/internal/config" "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/platform" "git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" "git.nakama.town/fmartingr/butterrobot/internal/plugin/social" "git.nakama.town/fmartingr/butterrobot/internal/queue" ) @@ -86,12 +88,19 @@ func (a *App) Run() error { plugin.Register(social.NewTwitterExpander()) plugin.Register(social.NewInstagramExpander()) + // Register reminder plugin + reminderPlugin := reminder.New(a.db) + plugin.Register(reminderPlugin) + // Initialize routes a.initializeRoutes() // Start message queue worker a.queue.Start(a.handleMessage) + // Start reminder scheduler + a.queue.StartReminderScheduler(a.handleReminder) + // Create server addr := fmt.Sprintf(":%s", a.config.Port) srv := &http.Server{ @@ -304,3 +313,73 @@ func (a *App) handleMessage(item queue.Item) { } } } + +// handleReminder handles reminder processing +func (a *App) handleReminder(reminder *model.Reminder) { + // When called with nil, it means we should check for pending reminders + if reminder == nil { + // Get pending reminders + reminders, err := a.db.GetPendingReminders() + if err != nil { + a.logger.Error("Error getting pending reminders", "error", err) + return + } + + // Process each reminder + for _, r := range reminders { + a.processReminder(r) + } + return + } + + // Otherwise, process the specific reminder + a.processReminder(reminder) +} + +// processReminder processes an individual reminder +func (a *App) processReminder(reminder *model.Reminder) { + a.logger.Info("Processing reminder", + "id", reminder.ID, + "platform", reminder.Platform, + "channel", reminder.ChannelID, + "trigger_at", reminder.TriggerAt, + ) + + // Get the platform handler + p, err := platform.Get(reminder.Platform) + if err != nil { + a.logger.Error("Error getting platform for reminder", "error", err, "platform", reminder.Platform) + return + } + + // Get the channel + channel, err := a.db.GetChannelByPlatform(reminder.Platform, reminder.ChannelID) + if err != nil { + a.logger.Error("Error getting channel for reminder", "error", err) + return + } + + // Create the reminder message + reminderText := fmt.Sprintf("@%s reminding you of this", reminder.Username) + + message := &model.Message{ + Text: reminderText, + Chat: reminder.ChannelID, + Channel: channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: reminder.ReplyToID, // Reply to the original message + } + + // Send the reminder message + if err := p.SendMessage(message); err != nil { + a.logger.Error("Error sending reminder", "error", err) + return + } + + // Mark the reminder as processed + if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { + a.logger.Error("Error marking reminder as processed", "error", err) + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 8cdce4a..b71b543 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" @@ -591,6 +592,120 @@ func (d *Database) UpdateUserPassword(userID int64, newPassword string) error { return err } +// CreateReminder creates a new reminder +func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) { + query := ` + INSERT INTO reminders ( + platform, channel_id, message_id, reply_to_id, + user_id, username, created_at, trigger_at, + content, processed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + ` + + createdAt := time.Now() + result, err := d.db.Exec( + query, + platform, channelID, messageID, replyToID, + userID, username, createdAt, triggerAt, + content, + ) + if err != nil { + return nil, err + } + + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + return &model.Reminder{ + ID: id, + Platform: platform, + ChannelID: channelID, + MessageID: messageID, + ReplyToID: replyToID, + UserID: userID, + Username: username, + CreatedAt: createdAt, + TriggerAt: triggerAt, + Content: content, + Processed: false, + }, nil +} + +// GetPendingReminders gets all pending reminders that need to be processed +func (d *Database) GetPendingReminders() ([]*model.Reminder, error) { + query := ` + SELECT id, platform, channel_id, message_id, reply_to_id, + user_id, username, created_at, trigger_at, content, processed + FROM reminders + WHERE processed = 0 AND trigger_at <= ? + ` + + rows, err := d.db.Query(query, time.Now()) + if err != nil { + return nil, err + } + defer rows.Close() + + var reminders []*model.Reminder + + for rows.Next() { + var ( + id int64 + platform, channelID, messageID, replyToID string + userID, username, content string + createdAt, triggerAt time.Time + processed bool + ) + + if err := rows.Scan( + &id, &platform, &channelID, &messageID, &replyToID, + &userID, &username, &createdAt, &triggerAt, &content, &processed, + ); err != nil { + return nil, err + } + + reminder := &model.Reminder{ + ID: id, + Platform: platform, + ChannelID: channelID, + MessageID: messageID, + ReplyToID: replyToID, + UserID: userID, + Username: username, + CreatedAt: createdAt, + TriggerAt: triggerAt, + Content: content, + Processed: processed, + } + + reminders = append(reminders, reminder) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + if len(reminders) == 0 { + return make([]*model.Reminder, 0), nil + } + + return reminders, nil +} + +// MarkReminderAsProcessed marks a reminder as processed +func (d *Database) MarkReminderAsProcessed(id int64) error { + query := ` + UPDATE reminders + SET processed = 1 + WHERE id = ? + ` + + _, err := d.db.Exec(query, id) + return err +} + // Helper function to hash password func hashPassword(password string) (string, error) { // Use bcrypt for secure password hashing @@ -609,25 +724,25 @@ func initDatabase(db *sql.DB) error { if err := migration.EnsureMigrationTable(db); err != nil { return fmt.Errorf("failed to create migration table: %w", err) } - + // Get applied migrations applied, err := migration.GetAppliedMigrations(db) if err != nil { return fmt.Errorf("failed to get applied migrations: %w", err) } - + // Get all migration versions allMigrations := make([]int, 0, len(migration.Migrations)) for version := range migration.Migrations { allMigrations = append(allMigrations, version) } - + // Create a map of applied migrations for quick lookup appliedMap := make(map[int]bool) for _, version := range applied { appliedMap[version] = true } - + // Count pending migrations pendingCount := 0 for _, version := range allMigrations { @@ -635,7 +750,7 @@ func initDatabase(db *sql.DB) error { pendingCount++ } } - + // Run migrations if needed if pendingCount > 0 { fmt.Printf("Running %d pending database migrations...\n", pendingCount) @@ -646,6 +761,6 @@ func initDatabase(db *sql.DB) error { } else { fmt.Println("Database schema is up to date.") } - + return nil } diff --git a/internal/migration/migration.go b/internal/migration/migration.go index 44096f3..dec4ff5 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -208,4 +208,4 @@ func MigrateDown(db *sql.DB, targetVersion int) error { } return nil -} \ No newline at end of file +} diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go index 2852113..8db229b 100644 --- a/internal/migration/migrations.go +++ b/internal/migration/migrations.go @@ -8,6 +8,7 @@ import ( func init() { // Register migrations Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) + Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown) } // Initial schema creation with bcrypt passwords - version 1 @@ -60,14 +61,14 @@ func migrateInitialSchemaUp(db *sql.DB) error { if err != nil { return err } - + // Check if users table is empty before inserting var count int err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) if err != nil { return err } - + if count == 0 { _, err = db.Exec( "INSERT INTO users (username, password) VALUES (?, ?)", @@ -99,4 +100,29 @@ func migrateInitialSchemaDown(db *sql.DB) error { } return nil -} \ No newline at end of file +} + +// Add reminders table - version 2 +func migrateRemindersUp(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + reply_to_id TEXT NOT NULL, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + trigger_at TIMESTAMP NOT NULL, + content TEXT NOT NULL, + processed BOOLEAN NOT NULL DEFAULT 0 + ) + `) + return err +} + +func migrateRemindersDown(db *sql.DB) error { + _, err := db.Exec(`DROP TABLE IF EXISTS reminders`) + return err +} diff --git a/internal/model/message.go b/internal/model/message.go index fe8c5e4..e6f86f6 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -6,25 +6,25 @@ import ( // Message represents a chat message type Message struct { - Text string - Chat string - Channel *Channel - Author string - FromBot bool - Date time.Time - ID string - ReplyTo string - Raw map[string]interface{} + Text string + Chat string + Channel *Channel + Author string + FromBot bool + Date time.Time + ID string + ReplyTo string + Raw map[string]interface{} } // Channel represents a chat channel type Channel struct { - ID int64 - Platform string + ID int64 + Platform string PlatformChannelID string - ChannelRaw map[string]interface{} - Enabled bool - Plugins map[string]*ChannelPlugin + ChannelRaw map[string]interface{} + Enabled bool + Plugins map[string]*ChannelPlugin } // HasEnabledPlugin checks if a plugin is enabled for this channel @@ -40,18 +40,18 @@ func (c *Channel) HasEnabledPlugin(pluginID string) bool { func (c *Channel) ChannelName() string { // In a real implementation, this would use the platform-specific // ParseChannelNameFromRaw function - + // For simplicity, we'll just use the PlatformChannelID if we can't extract a name // Check if ChannelRaw has a name field if c.ChannelRaw == nil { return c.PlatformChannelID } - + // Check common name fields in ChannelRaw if name, ok := c.ChannelRaw["name"].(string); ok && name != "" { return name } - + // Check for nested objects like "chat" (used by Telegram) if chat, ok := c.ChannelRaw["chat"].(map[string]interface{}); ok { // Try different fields in order of preference @@ -65,7 +65,7 @@ func (c *Channel) ChannelName() string { return firstName } } - + return c.PlatformChannelID } @@ -83,4 +83,19 @@ type User struct { ID int64 Username string Password string -} \ No newline at end of file +} + +// Reminder represents a scheduled reminder +type Reminder struct { + ID int64 + Platform string + ChannelID string + MessageID string + ReplyToID string + UserID string + Username string + CreatedAt time.Time + TriggerAt time.Time + Content string + Processed bool +} diff --git a/internal/model/plugin.go b/internal/model/plugin.go index ffc3c2f..9f2b34a 100644 --- a/internal/model/plugin.go +++ b/internal/model/plugin.go @@ -13,16 +13,16 @@ var ( type Plugin interface { // GetID returns the plugin ID GetID() string - + // GetName returns the plugin name GetName() string - + // GetHelp returns the plugin help text GetHelp() string - + // RequiresConfig indicates if the plugin requires configuration RequiresConfig() bool - + // OnMessage processes an incoming message and returns response messages OnMessage(msg *Message, config map[string]interface{}) []*Message -} \ No newline at end of file +} diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index a9ff2db..6c9a2b3 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -103,8 +103,11 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message Title string `json:"title,omitempty"` Username string `json:"username,omitempty"` } `json:"chat"` - Date int `json:"date"` - Text string `json:"text"` + Date int `json:"date"` + Text string `json:"text"` + ReplyToMessage struct { + MessageID int `json:"message_id"` + } `json:"reply_to_message"` } `json:"message"` } @@ -128,6 +131,7 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message FromBot: update.Message.From.IsBot, Date: time.Unix(int64(update.Message.Date), 0), ID: strconv.Itoa(update.Message.MessageID), + ReplyTo: strconv.Itoa(update.Message.ReplyToMessage.MessageID), Raw: raw, } @@ -259,4 +263,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil -} \ No newline at end of file +} diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go new file mode 100644 index 0000000..6d7c1aa --- /dev/null +++ b/internal/plugin/reminder/reminder.go @@ -0,0 +1,178 @@ +package reminder + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// Duration regex patterns to match reminders +var ( + remindMePattern = regexp.MustCompile(`(?i)^!remindme\s(\d+)(y|mo|d|h|m|s)$`) +) + +// ReminderCreator is an interface for creating reminders +type ReminderCreator interface { + CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) +} + +// Reminder is a plugin that sets reminders for messages +type Reminder struct { + plugin.BasePlugin + creator ReminderCreator +} + +// New creates a new Reminder plugin +func New(creator ReminderCreator) *Reminder { + return &Reminder{ + BasePlugin: plugin.BasePlugin{ + ID: "reminder.remindme", + Name: "Remind Me", + Help: "Reply to a message with `!remindme ` to set a reminder (e.g., `!remindme 2d` for 2 days, `!remindme 1y` for 1 year).", + ConfigRequired: false, + }, + creator: creator, + } +} + +// OnMessage processes incoming messages +func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Only process replies to messages + if msg.ReplyTo == "" { + return []*model.Message{ + { + Text: "Please reply to a message with `!remindme ` to set a reminder.", + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + } + } + + // Check if the message is a reminder command + match := remindMePattern.FindStringSubmatch(msg.Text) + if match == nil { + return nil + } + + // Parse the duration + amount, err := strconv.Atoi(match[1]) + if err != nil { + return []*model.Message{ + { + Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } + } + + // Calculate the trigger time + var duration time.Duration + unit := match[2] + switch strings.ToLower(unit) { + case "y": + duration = time.Duration(amount) * 365 * 24 * time.Hour + case "mo": + duration = time.Duration(amount) * 30 * 24 * time.Hour + case "d": + duration = time.Duration(amount) * 24 * time.Hour + case "h": + duration = time.Duration(amount) * time.Hour + case "m": + duration = time.Duration(amount) * time.Minute + case "s": + duration = time.Duration(amount) * time.Second + default: + return []*model.Message{ + { + Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } + } + + triggerAt := time.Now().Add(duration) + + // Determine the username for the reminder + username := msg.Author + if username == "" { + // Try to extract username from message raw data + if authorData, ok := msg.Raw["author"].(map[string]interface{}); ok { + if name, ok := authorData["username"].(string); ok { + username = name + } else if name, ok := authorData["name"].(string); ok { + username = name + } + } + } + + // Create the reminder + _, err = r.creator.CreateReminder( + msg.Channel.Platform, + msg.Chat, + msg.ID, + msg.ReplyTo, + msg.Author, + username, + "", // No additional content for now + triggerAt, + ) + + if err != nil { + return []*model.Message{ + { + Text: fmt.Sprintf("Failed to create reminder: %v", err), + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } + } + + // Format the acknowledgment message + var confirmText string + switch strings.ToLower(unit) { + case "y": + confirmText = fmt.Sprintf("I'll remind you about this message in %d year(s) on %s", amount, triggerAt.Format("Mon, Jan 2, 2006 at 15:04")) + case "mo": + confirmText = fmt.Sprintf("I'll remind you about this message in %d month(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04")) + case "d": + confirmText = fmt.Sprintf("I'll remind you about this message in %d day(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04")) + case "h": + confirmText = fmt.Sprintf("I'll remind you about this message in %d hour(s) at %s", amount, triggerAt.Format("15:04")) + case "m": + confirmText = fmt.Sprintf("I'll remind you about this message in %d minute(s) at %s", amount, triggerAt.Format("15:04")) + case "s": + confirmText = fmt.Sprintf("I'll remind you about this message in %d second(s)", amount) + } + + return []*model.Message{ + { + Text: confirmText, + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } +} diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go new file mode 100644 index 0000000..b76fd2f --- /dev/null +++ b/internal/plugin/reminder/reminder_test.go @@ -0,0 +1,164 @@ +package reminder + +import ( + "testing" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +// MockCreator is a mock implementation of ReminderCreator for testing +type MockCreator struct { + reminders []*model.Reminder +} + +func (m *MockCreator) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) { + reminder := &model.Reminder{ + ID: int64(len(m.reminders) + 1), + Platform: platform, + ChannelID: channelID, + MessageID: messageID, + ReplyToID: replyToID, + UserID: userID, + Username: username, + Content: content, + TriggerAt: triggerAt, + } + m.reminders = append(m.reminders, reminder) + return reminder, nil +} + +func TestReminderOnMessage(t *testing.T) { + creator := &MockCreator{reminders: make([]*model.Reminder, 0)} + plugin := New(creator) + + tests := []struct { + name string + message *model.Message + expectResponse bool + expectReminder bool + }{ + { + name: "Valid reminder command - years", + message: &model.Message{ + Text: "!remindme 1y", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - months", + message: &model.Message{ + Text: "!remindme 3mo", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - days", + message: &model.Message{ + Text: "!remindme 2d", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - hours", + message: &model.Message{ + Text: "!remindme 5h", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - minutes", + message: &model.Message{ + Text: "!remindme 30m", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - seconds", + message: &model.Message{ + Text: "!remindme 60s", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Not a reply", + message: &model.Message{ + Text: "!remindme 2d", + ReplyTo: "", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: false, + expectReminder: false, + }, + { + name: "Not a reminder command", + message: &model.Message{ + Text: "hello world", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: false, + expectReminder: false, + }, + { + name: "Invalid duration format", + message: &model.Message{ + Text: "!remindme abc", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: false, + expectReminder: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initialCount := len(creator.reminders) + responses := plugin.OnMessage(tt.message, nil) + + if tt.expectResponse && len(responses) == 0 { + t.Errorf("Expected response, but got none") + } + + if !tt.expectResponse && len(responses) > 0 { + t.Errorf("Expected no response, but got %d", len(responses)) + } + + if tt.expectReminder && len(creator.reminders) != initialCount+1 { + t.Errorf("Expected reminder to be created, but it wasn't") + } + + if !tt.expectReminder && len(creator.reminders) != initialCount { + t.Errorf("Expected no reminder to be created, but got %d", len(creator.reminders)-initialCount) + } + }) + } +} \ No newline at end of file diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 668bf60..692816e 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -3,6 +3,9 @@ package queue import ( "log/slog" "sync" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" ) // Item represents a queue item @@ -14,14 +17,19 @@ type Item struct { // HandlerFunc defines a function that processes queue items type HandlerFunc func(item Item) +// ReminderHandlerFunc defines a function that processes reminder items +type ReminderHandlerFunc func(reminder *model.Reminder) + // Queue represents a message queue type Queue struct { - items chan Item - wg sync.WaitGroup - quit chan struct{} - logger *slog.Logger - running bool - runMutex sync.Mutex + items chan Item + wg sync.WaitGroup + quit chan struct{} + logger *slog.Logger + running bool + runMutex sync.Mutex + reminderTicker *time.Ticker + reminderHandler ReminderHandlerFunc } // New creates a new Queue instance @@ -49,6 +57,24 @@ func (q *Queue) Start(handler HandlerFunc) { go q.worker(handler) } +// StartReminderScheduler starts the reminder scheduler +func (q *Queue) StartReminderScheduler(handler ReminderHandlerFunc) { + q.runMutex.Lock() + defer q.runMutex.Unlock() + + if q.reminderTicker != nil { + return + } + + q.reminderHandler = handler + + // Check for reminders every minute + q.reminderTicker = time.NewTicker(1 * time.Minute) + + q.wg.Add(1) + go q.reminderWorker() +} + // Stop stops processing queue items func (q *Queue) Stop() { q.runMutex.Lock() @@ -59,6 +85,12 @@ func (q *Queue) Stop() { } q.running = false + + // Stop reminder ticker if it exists + if q.reminderTicker != nil { + q.reminderTicker.Stop() + } + close(q.quit) q.wg.Wait() } @@ -96,4 +128,34 @@ func (q *Queue) worker(handler HandlerFunc) { return } } -} \ No newline at end of file +} + +// reminderWorker processes reminder items on a schedule +func (q *Queue) reminderWorker() { + defer q.wg.Done() + + for { + select { + case <-q.reminderTicker.C: + // This is triggered every minute to check for pending reminders + q.logger.Debug("Checking for pending reminders") + + if q.reminderHandler != nil { + // The handler is responsible for fetching and processing reminders + func() { + defer func() { + if r := recover(); r != nil { + q.logger.Error("Panic in reminder worker", "error", r) + } + }() + + // Call the handler with a nil reminder to indicate it should check the database + q.reminderHandler(nil) + }() + } + case <-q.quit: + // Quit worker + return + } + } +} From 323ea4e8cdfc7ac6c2226317629fbd1ff0782f7e Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:40:10 +0200 Subject: [PATCH 41/44] fix(ci): updated woodpecker triggers --- .woodpecker/ci.yml | 2 +- .woodpecker/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 4353088..5b32d48 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -3,7 +3,7 @@ when: - push - pull_request branch: - - main + - master steps: format: diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 3630566..39dbf65 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -1,6 +1,6 @@ when: - event: tag - branch: main + branch: master steps: - name: Release From abcd3c3c44b96951d1c3b297741bb7b57499f99b Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:41:56 +0200 Subject: [PATCH 42/44] docs: updated README --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 214afa6..920d087 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # Butter Robot -| Stable | Master | -| --- | --- | -| ![Build stable tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) | ![Build latest tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) | -| ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=stable) | ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=master) | +![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg) Go framework to create bots for several platforms. @@ -13,7 +10,7 @@ Go framework to create bots for several platforms. ## Features -- Support for multiple chat platforms (Slack, Telegram) +- Support for multiple chat platforms (Slack (untested!), Telegram) - Plugin system for easy extension - Admin interface for managing channels and plugins - Message queue for asynchronous processing From 763a451251c95c609887c64de4eda4fe01bbf39b Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:56:33 +0200 Subject: [PATCH 43/44] fix: lint errors --- internal/admin/admin.go | 19 +++++----------- internal/app/app.go | 18 ++++++++++----- internal/db/db.go | 27 ++++++++++++++++------- internal/migration/migration.go | 24 +++++++++++++++----- internal/platform/slack/slack.go | 18 ++++++++++----- internal/platform/telegram/telegram.go | 20 +++++++++++++---- internal/plugin/fun/dice.go | 5 +++-- internal/plugin/reminder/reminder.go | 9 +------- internal/plugin/reminder/reminder_test.go | 2 +- internal/plugin/social/instagram.go | 4 +--- 10 files changed, 91 insertions(+), 55 deletions(-) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index c2a78ca..e20c7c0 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -194,7 +194,7 @@ func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, } // Map internal categories to Bootstrap alert classes - alertClass := category + var alertClass string switch category { case "success": alertClass = "success" @@ -249,16 +249,6 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag return messages } -// requireLogin middleware checks if the user is logged in -func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - next(w, r) - } -} // render renders a template with the given data func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { @@ -334,7 +324,10 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // Set session expiration session.Options.MaxAge = 3600 * 24 * 7 // 1 week - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session: %v\n", err) + } a.addFlash(w, r, "You were logged in", "success") @@ -715,4 +708,4 @@ func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http // Redirect to channel plugins list http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) -} +} \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 1d878ab..ece6908 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -152,7 +152,9 @@ func (a *App) initializeRoutes() { a.router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{}) + if err := json.NewEncoder(w).Encode(map[string]interface{}{}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } }) // Platform webhook endpoints @@ -175,7 +177,9 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { if _, err := platform.Get(platformName); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } return } @@ -184,7 +188,9 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } return } @@ -200,7 +206,9 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { // Respond with success w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]any{}) + if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } } // extractPlatformName extracts the platform name from the URL path @@ -382,4 +390,4 @@ func (a *App) processReminder(reminder *model.Reminder) { if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { a.logger.Error("Error marking reminder as processed", "error", err) } -} +} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index b71b543..328d460 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -234,7 +234,11 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var plugins []*model.ChannelPlugin @@ -415,7 +419,11 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var channels []*model.Channel @@ -454,10 +462,9 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { continue // Skip this channel if plugins can't be retrieved } - if plugins != nil { - for _, plugin := range plugins { - channel.Plugins[plugin.PluginID] = plugin - } + // Add plugins to channel + for _, plugin := range plugins { + channel.Plugins[plugin.PluginID] = plugin } channels = append(channels, channel) @@ -646,7 +653,11 @@ func (d *Database) GetPendingReminders() ([]*model.Reminder, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var reminders []*model.Reminder @@ -763,4 +774,4 @@ func initDatabase(db *sql.DB) error { } return nil -} +} \ No newline at end of file diff --git a/internal/migration/migration.go b/internal/migration/migration.go index dec4ff5..2067755 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -49,7 +49,11 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var versions []int for rows.Next() { @@ -128,7 +132,9 @@ func Migrate(db *sql.DB) error { // Apply the migration if err := migration.Up(db); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to apply migration %d: %w", version, err) } @@ -137,7 +143,9 @@ func Migrate(db *sql.DB) error { "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", version, time.Now(), ); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to mark migration %d as applied: %w", version, err) } @@ -188,13 +196,17 @@ func MigrateDown(db *sql.DB, targetVersion int) error { // Apply the down migration if err := migration.Down(db); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to roll back migration %d: %w", version, err) } // Remove from applied list if _, err := tx.Exec("DELETE FROM schema_migrations WHERE version = ?", version); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err) } @@ -208,4 +220,4 @@ func MigrateDown(db *sql.DB, targetVersion int) error { } return nil -} +} \ No newline at end of file diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index 3683ada..efa7c5d 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "strings" "time" @@ -37,11 +37,15 @@ func (s *SlackPlatform) Init(_ *config.Config) error { // ParseIncomingMessage parses an incoming Slack message func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) { // Read request body - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { return nil, err } - defer r.Body.Close() + defer func() { + if err := r.Body.Close(); err != nil { + fmt.Printf("Error closing request body: %v\n", err) + } + }() // Parse JSON var requestData map[string]interface{} @@ -194,7 +198,11 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("Error closing response body: %v\n", err) + } + }() // Check response if resp.StatusCode != http.StatusOK { @@ -209,4 +217,4 @@ func parseInt64(s string) (int64, error) { var n int64 _, err := fmt.Sscanf(s, "%d", &n) return n, err -} +} \ No newline at end of file diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index 6c9a2b3..baf26d0 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -62,7 +62,11 @@ func (t *TelegramPlatform) Init(cfg *config.Config) error { t.log.Error("Failed to set webhook", "error", err) return fmt.Errorf("failed to set webhook: %w", err) } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + t.log.Error("Error closing response body", "error", err) + } + }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) @@ -85,7 +89,11 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message t.log.Error("Failed to read request body", "error", err) return nil, err } - defer r.Body.Close() + defer func() { + if err := r.Body.Close(); err != nil { + t.log.Error("Error closing request body", "error", err) + } + }() // Parse JSON var update struct { @@ -251,7 +259,11 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Error("Failed to send message", "error", err) return err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + t.log.Error("Error closing response body", "error", err) + } + }() // Check response if resp.StatusCode != http.StatusOK { @@ -263,4 +275,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil -} +} \ No newline at end of file diff --git a/internal/plugin/fun/dice.go b/internal/plugin/fun/dice.go index 00fc7cc..2d5533b 100644 --- a/internal/plugin/fun/dice.go +++ b/internal/plugin/fun/dice.go @@ -107,9 +107,10 @@ func (p *DicePlugin) rollDice(formula string) (int, error) { return 0, fmt.Errorf("invalid modifier") } - if matches[3] == "+" { + switch matches[3] { + case "+": total += modifier - } else if matches[3] == "-" { + case "-": total -= modifier } } diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go index 6d7c1aa..5eb47f9 100644 --- a/internal/plugin/reminder/reminder.go +++ b/internal/plugin/reminder/reminder.go @@ -44,14 +44,7 @@ func New(creator ReminderCreator) *Reminder { func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { // Only process replies to messages if msg.ReplyTo == "" { - return []*model.Message{ - { - Text: "Please reply to a message with `!remindme ` to set a reminder.", - Chat: msg.Chat, - Channel: msg.Channel, - ReplyTo: msg.ID, - }, - } + return nil } // Check if the message is a reminder command diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go index b76fd2f..3070918 100644 --- a/internal/plugin/reminder/reminder_test.go +++ b/internal/plugin/reminder/reminder_test.go @@ -161,4 +161,4 @@ func TestReminderOnMessage(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index a4f758a..7ff74a5 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -53,9 +53,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte } // Change the host - if strings.Contains(parsedURL.Host, "instagram.com") { - parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) - } + parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) // Remove query parameters parsedURL.RawQuery = "" From c9edb57505314aa573d3bdddd1aa2e96abdc76de Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:56:57 +0200 Subject: [PATCH 44/44] fix: make format --- internal/admin/admin.go | 3 +-- internal/app/app.go | 2 +- internal/db/db.go | 2 +- internal/migration/migration.go | 2 +- internal/platform/slack/slack.go | 2 +- internal/platform/telegram/telegram.go | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index e20c7c0..69c769b 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -249,7 +249,6 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag return messages } - // render renders a template with the given data func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { // Add current user data @@ -708,4 +707,4 @@ func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http // Redirect to channel plugins list http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) -} \ No newline at end of file +} diff --git a/internal/app/app.go b/internal/app/app.go index ece6908..7403396 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -390,4 +390,4 @@ func (a *App) processReminder(reminder *model.Reminder) { if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { a.logger.Error("Error marking reminder as processed", "error", err) } -} \ No newline at end of file +} diff --git a/internal/db/db.go b/internal/db/db.go index 328d460..bdf9eaf 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -774,4 +774,4 @@ func initDatabase(db *sql.DB) error { } return nil -} \ No newline at end of file +} diff --git a/internal/migration/migration.go b/internal/migration/migration.go index 2067755..63da5d8 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -220,4 +220,4 @@ func MigrateDown(db *sql.DB, targetVersion int) error { } return nil -} \ No newline at end of file +} diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index efa7c5d..9c12b1f 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -217,4 +217,4 @@ func parseInt64(s string) (int64, error) { var n int64 _, err := fmt.Sscanf(s, "%d", &n) return n, err -} \ No newline at end of file +} diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index baf26d0..0edb729 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -275,4 +275,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil -} \ No newline at end of file +}