diff --git a/Taskfile.yaml b/Taskfile.yaml index 7e91407..019afdf 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -14,4 +14,9 @@ tasks: docker:build: desc: Build the container using Docker cmds: - - docker build . -t smsbot:latest \ No newline at end of file + - docker build . -t smsbot:latest + + smsbot:run: + desc: Run the SMSBot + cmds: + - uv run smsbot \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index efeec3e..4ea82ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,18 @@ [project] name = "smsbot" -version = "0.0.7" +version = "0.1.0" description = "A simple Telegram bot to receive SMS messages." authors = [{ name = "Andrew Williams", email = "andy@tensixtyone.com" }] license = { text = "MIT" } readme = "README.md" requires-python = ">=3.9,<3.10" dependencies = [ - "flask>=3.1.1", + "flask[async]>=3.1.1", + "prometheus-async>=25.1.0", "prometheus-client>=0.22.1", - "python-telegram-bot<20", + "python-telegram-bot>=22.3", "twilio>=9.7.0", - "waitress>=3.0.2", + "uvicorn>=0.35.0", ] [project.scripts] diff --git a/smsbot/cli.py b/smsbot/cli.py index c5fd2b3..d5a0f87 100644 --- a/smsbot/cli.py +++ b/smsbot/cli.py @@ -1,6 +1,11 @@ import argparse +import asyncio import logging import os +from signal import SIGINT, SIGTERM + +import uvicorn +from asgiref.wsgi import WsgiToAsgi from smsbot.telegram import TelegramSmsBot from smsbot.utils import get_smsbot_version @@ -13,7 +18,7 @@ def main(): "--listen-host", default=os.environ.get("SMSBOT_LISTEN_HOST") or "0.0.0.0" ) parser.add_argument( - "--listen-port", default=os.environ.get("SMSBOT_LISTEN_PORT") or "80" + "--listen-port", default=os.environ.get("SMSBOT_LISTEN_PORT") or 80, type=int ) parser.add_argument( "--telegram-bot-token", default=os.environ.get("SMSBOT_TELEGRAM_BOT_TOKEN") @@ -22,31 +27,74 @@ def main(): parser.add_argument( "--default-subscribers", default=os.environ.get("SMSBOT_DEFAULT_SUBSCRIBERS") ) - parser.add_argument("--log-level", default="INFO") + parser.add_argument("--debug", action="store_true") args = parser.parse_args() - # TODO: Replace for Py >=3.11 - logging.basicConfig(level=logging.getLevelName(args.log_level)) + if args.debug: + level = logging.DEBUG + else: + level = logging.INFO + logging.basicConfig(level=level) logging.info("smsbot v%s", get_smsbot_version()) logging.debug("Arguments: %s", args) # Start bot - telegram_bot = TelegramSmsBot(args.telegram_bot_token) + telegram_bot = TelegramSmsBot(token=args.telegram_bot_token) # Set the owner ID if configured if args.owner_id: - telegram_bot.set_owner(args.owner_id) + telegram_bot.owners = [int(args.owner_id)] else: logging.warning("No Owner ID is set, which is not a good idea...") # Add default subscribers if args.default_subscribers: for chat_id in args.default_subscribers.split(","): - telegram_bot.add_subscriber(chat_id) + telegram_bot.subscribers.append(int(chat_id.strip())) - telegram_bot.start() - - # Start webhooks webhooks = TwilioWebhookHandler() webhooks.set_bot(telegram_bot) - webhooks.serve(host=args.listen_host, port=args.listen_port) + + # Build a uvicorn ASGI server + flask_app = uvicorn.Server( + config=uvicorn.Config( + app=WsgiToAsgi(webhooks.app), + port=args.listen_port, + use_colors=False, + host=args.listen_host, + ) + ) + + # Loop until exit + loop = asyncio.get_event_loop() + main_task = asyncio.ensure_future(run_bot(telegram_bot, flask_app)) + for signal in [SIGINT, SIGTERM]: + loop.add_signal_handler(signal, main_task.cancel) + try: + loop.run_until_complete(main_task) + # Catch graceful shutdowns + except asyncio.CancelledError: + pass + finally: + loop.close() + + +async def run_bot(telegram_bot: TelegramSmsBot, flask_app): + # Start async Telegram bot + try: + # Start the bot + await telegram_bot.app.initialize() + await telegram_bot.app.start() + await telegram_bot.app.updater.start_polling() + + # Startup uvicorn/flask + await flask_app.serve() + + # Run the bot idle loop + await telegram_bot.app.updater.idle() + finally: + # Shutdown in reverse order + await flask_app.shutdown() + await telegram_bot.app.updater.stop() + await telegram_bot.app.stop() + await telegram_bot.app.shutdown() diff --git a/smsbot/telegram.py b/smsbot/telegram.py index 60ec9d6..b2181e6 100644 --- a/smsbot/telegram.py +++ b/smsbot/telegram.py @@ -1,9 +1,15 @@ import logging -from prometheus_client import Counter, Summary -from telegram.ext import CommandHandler, Updater - from smsbot.utils import get_smsbot_version +from prometheus_client import Counter, Summary +from telegram import Update +from telegram.ext import ( + Application, + ApplicationHandlerStop, + ContextTypes, + TypeHandler, + CommandHandler, +) REQUEST_TIME = Summary( "telegram_request_processing_seconds", "Time spent processing request" @@ -11,108 +17,94 @@ REQUEST_TIME = Summary( COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands processed") -class TelegramSmsBot(object): - def __init__( - self, telegram_token, allow_subscribing=False, owner=None, subscribers=None - ): +class TelegramSmsBot: + def __init__(self, token: str, owners: list[int] = [], subscribers: list[int] = []): self.logger = logging.getLogger(self.__class__.__name__) - self.bot_token = telegram_token - self.subscriber_ids = subscribers or [] - self.set_owner(owner) + self.app = Application.builder().token(token).build() + self.owners = owners + self.subscribers = subscribers - self.updater = Updater(self.bot_token, use_context=True) - self.updater.dispatcher.add_handler(CommandHandler("help", self.help_handler)) - self.updater.dispatcher.add_handler(CommandHandler("start", self.help_handler)) + self.init_handlers() - if allow_subscribing: - self.updater.dispatcher.add_handler( - CommandHandler("subscribe", self.subscribe_handler) - ) - self.updater.dispatcher.add_handler( - CommandHandler("unsubscribe", self.unsubscribe_handler) + def init_handlers(self): + self.app.add_handler(TypeHandler(Update, self.callback), -1) + self.app.add_handler(CommandHandler("help", self.handler_help)) + self.app.add_handler(CommandHandler("subscribe", self.handler_subscribe)) + self.app.add_handler(CommandHandler("unsubscribe", self.handler_unsubscribe)) + + async def callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle the update""" + if update.effective_user.id in self.owners: + self.logger.info( + f"{update.effective_user.username} sent {update.message.text}" ) + COMMAND_COUNT.inc() + else: + self.logger.debug(f"Ignoring message from user {update.effective_user.id}") + raise ApplicationHandlerStop - self.updater.dispatcher.add_error_handler(self.error_handler) + async def send_message(self, chat_id: int, text: str): + """Send a message to a specific chat""" + self.logger.info(f"Sending message to chat {chat_id}: {text}") + await self.app.bot.send_message( + chat_id=chat_id, text=text, parse_mode="MarkdownV2" + ) - def start(self): - self.logger.info("Starting bot...") - self.updater.start_polling() - self.bot = self.updater.bot - self.logger.info("Bot Ready") + async def send_subscribers(self, text: str): + """Send a message to all subscribers""" + for subscriber in self.subscribers: + self.logger.info(f"Sending message to subscriber {subscriber}") + await self.send_message(subscriber, text) - def stop(self): - self.updater.stop() + async def send_owners(self, text: str): + """Send a message to all owners""" + for owner in self.owners: + self.logger.info(f"Sending message to owner {owner}") + await self.send_message(owner, text) @REQUEST_TIME.time() - def help_handler(self, update, context): + async def handler_help(self, update, context): """Send a message when the command /help is issued.""" self.logger.info("/help command received in chat: %s", update.message.chat) commands = [] - for command in self.updater.dispatcher.handlers[0]: - commands.extend(["/{0}".format(cmd) for cmd in command.command]) + for command in self.app.handlers[0]: + if isinstance(command, CommandHandler): + commands.extend(["/{0}".format(cmd) for cmd in command.commands]) - update.message.reply_markdown( + await update.message.reply_markdown( "Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands)) ) COMMAND_COUNT.inc() @REQUEST_TIME.time() - def subscribe_handler(self, update, context): - self.logger.info("/subscribe command received") - if update.message.chat["id"] not in self.subscriber_ids: - self.logger.info("{0} subscribed".format(update.message.chat["username"])) - self.subscriber_ids.append(update.message.chat["id"]) - self.send_owner( - "{0} has subscribed".format(update.message.chat["username"]) - ) - update.message.reply_markdown( - "You have been subscribed to SMS notifications" + async def handler_subscribe( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ): + """Handle subscription requests""" + user_id = update.effective_user.id + if user_id not in self.subscribers: + self.subscribers.append(user_id) + self.logger.info(f"User {user_id} subscribed.") + self.logger.info(f"Current subscribers: {self.subscribers}") + await update.message.reply_markdown( + "You have successfully subscribed to updates." ) else: - update.message.reply_markdown( - "You are already subscribed to SMS notifications" - ) - COMMAND_COUNT.inc() + self.logger.info(f"User {user_id} is already subscribed.") @REQUEST_TIME.time() - def unsubscribe_handler(self, update, context): - self.logger.info("/unsubscribe command received") - if update.message.chat["id"] in self.subscriber_ids: - self.logger.info("{0} unsubscribed".format(update.message.chat["username"])) - self.subscriber_ids.remove(update.message.chat["id"]) - self.send_owner( - "{0} has unsubscribed".format(update.message.chat["username"]) - ) - update.message.reply_markdown( - "You have been unsubscribed to SMS notifications" + async def handler_unsubscribe( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ): + """Handle unsubscription requests""" + user_id = update.effective_user.id + if user_id in self.subscribers: + self.subscribers.remove(user_id) + self.logger.info(f"User {user_id} unsubscribed.") + self.logger.info(f"Current subscribers: {self.subscribers}") + await update.message.reply_markdown( + "You have successfully unsubscribed from updates." ) else: - update.message.reply_markdown("You are not subscribed to SMS notifications") - COMMAND_COUNT.inc() - - def error_handler(self, update, context): - """Log Errors caused by Updates.""" - self.logger.warning('Update "%s" caused error "%s"', update, context.error) - self.send_owner( - 'Update "%{0}" caused error "{1}"'.format(update, context.error) - ) - - def send_message(self, message, chat_id): - self.bot.sendMessage(text=message, chat_id=chat_id) - - def send_owner(self, message): - if self.owner_id: - self.send_message(message, self.owner_id) - - def send_subscribers(self, message): - for chat_id in self.subscriber_ids: - self.send_message(message, chat_id) - - def set_owner(self, chat_id): - self.owner_id = chat_id - if self.owner_id and self.owner_id not in self.subscriber_ids: - self.subscriber_ids.append(self.owner_id) - - def add_subscriber(self, chat_id): - self.subscriber_ids.append(chat_id) + self.logger.info(f"User {user_id} is not subscribed.") diff --git a/smsbot/utils.py b/smsbot/utils.py index e129482..fd324dd 100644 --- a/smsbot/utils.py +++ b/smsbot/utils.py @@ -3,3 +3,57 @@ from importlib.metadata import version def get_smsbot_version(): return version("smsbot") + + +class TwilioMessage: + """ + Parses a Twilio webhook message. + """ + + def __init__(self, data: dict) -> None: + self.from_number: str = data.get("From", "Unknown") + self.to_number: str = data.get("To", "Unknown") + self.body: str = data.get("Body", "") + + self.media = [] + for i in range(0, int(data.get("NumMedia", "0"))): + self.media.append(data.get(f"MediaUrl{i}")) + + def _escape(self, text: str) -> str: + """Escape text for MarkdownV2""" + characters = [ + "_", + "*", + "[", + "]", + "(", + ")", + "~", + "`", + ">", + "#", + "+", + "-", + "=", + "|", + "{", + "}", + ".", + "!", + ] + for char in characters: + text = text.replace(char, rf"\{char}") + return text + + def __repr__(self) -> str: + return f"TwilioWebhookMessage(from={self.from_number}, to={self.to_number})" + + def to_str(self) -> str: + media_str = "\n".join([f"<{url}>" for url in self.media]) if self.media else "" + msg = f"**From**: {self.from_number}\n**To**: {self.to_number}\n\n{self.body}\n\n{media_str}" + return msg + + def to_markdownv2(self): + media_str = "\n".join([f"{self._escape(url)}" for url in self.media]) if self.media else "" + msg = f"**From**: {self._escape(self.from_number)}\n**To**: {self._escape(self.to_number)}\n\n{self._escape(self.body)}\n\n{media_str}" + return msg diff --git a/smsbot/webhook_handler.py b/smsbot/webhook_handler.py index 8f176c9..009a787 100644 --- a/smsbot/webhook_handler.py +++ b/smsbot/webhook_handler.py @@ -2,14 +2,16 @@ import os from functools import wraps from flask import Flask, abort, current_app, request +from prometheus_async.aio import time from prometheus_client import Counter, Summary, make_wsgi_app from twilio.request_validator import RequestValidator -from waitress import serve from werkzeug.middleware.dispatcher import DispatcherMiddleware -from smsbot.utils import get_smsbot_version +from smsbot.utils import TwilioMessage, get_smsbot_version -REQUEST_TIME = Summary("webhook_request_processing_seconds", "Time spent processing request") +REQUEST_TIME = Summary( + "webhook_request_processing_seconds", "Time spent processing request" +) MESSAGE_COUNT = Counter("webhook_message_count", "Total number of messages processed") CALL_COUNT = Counter("webhook_call_count", "Total number of calls processed") @@ -18,7 +20,7 @@ def validate_twilio_request(func): """Validates that incoming requests genuinely originated from Twilio""" @wraps(func) - def decorated_function(*args, **kwargs): # noqa: WPS430 + async def decorated_function(*args, **kwargs): # Create an instance of the RequestValidator class twilio_token = os.environ.get("SMSBOT_TWILIO_AUTH_TOKEN") @@ -26,7 +28,7 @@ def validate_twilio_request(func): current_app.logger.warning( "Twilio request validation skipped due to SMSBOT_TWILIO_AUTH_TOKEN missing" ) - return func(*args, **kwargs) + return await func(*args, **kwargs) validator = RequestValidator(twilio_token) @@ -63,47 +65,47 @@ class TwilioWebhookHandler(object): }, ) - def set_bot(self, bot): # noqa: WPS615 + def set_bot(self, bot): self.bot = bot - def index(self): - return "" + async def index(self): + return f'smsbot v{get_smsbot_version()} - GitHub' - @REQUEST_TIME.time() - def health(self): + async def health(self): + """Return basic health information""" return { "version": get_smsbot_version(), - "owner": self.bot.owner_id, - "subscribers": self.bot.subscriber_ids, + "owners": self.bot.owners, + "subscribers": self.bot.subscribers, } - @REQUEST_TIME.time() + @time(REQUEST_TIME) @validate_twilio_request - def message(self): + async def message(self): + """Handle incoming SMS messages from Twilio""" current_app.logger.info( "Received SMS from {From}: {Body}".format(**request.values.to_dict()) ) - message = "From: {From}\n\n{Body}".format(**request.values.to_dict()) - self.bot.send_subscribers(message) + await self.bot.send_subscribers( + TwilioMessage(request.values.to_dict()).to_markdownv2() + ) # Return a blank response MESSAGE_COUNT.inc() - return "" + return '' - @REQUEST_TIME.time() + @time(REQUEST_TIME) @validate_twilio_request - def call(self): + async def call(self): + """Handle incoming calls from Twilio""" current_app.logger.info( "Received Call from {From}".format(**request.values.to_dict()) ) - self.bot.send_subscribers( + await self.bot.send_subscribers( "Received Call from {From}, rejecting.".format(**request.values.to_dict()) ) # Always reject calls CALL_COUNT.inc() - return "" - - def serve(self, host="0.0.0.0", port=80, debug=False): - serve(self.app, host=host, port=port) + return '' diff --git a/tests/test_basic.py b/tests/test_basic.py index 3777690..3b5b1c6 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,2 +1,3 @@ def test_import(): - import smsbot \ No newline at end of file + import smsbot.utils + assert smsbot.utils.get_smsbot_version() \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..1ed3204 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,69 @@ +from smsbot.utils import TwilioMessage + + +def test_twiliomessage_normal(): + instance = TwilioMessage( + { + "From": "+1234567890", + "To": "+0987654321", + "Body": "Hello, world!", + "NumMedia": "2", + "MediaUrl0": "http://example.com/media1.jpg", + "MediaUrl1": "http://example.com/media2.jpg", + } + ) + + assert instance.from_number == "+1234567890" + assert instance.to_number == "+0987654321" + assert instance.body == "Hello, world!" + assert instance.media == [ + "http://example.com/media1.jpg", + "http://example.com/media2.jpg", + ] + + +def test_twiliomessage_no_media(): + instance = TwilioMessage( + { + "From": "+1234567890", + "To": "+0987654321", + "Body": "Hello, world!", + } + ) + + assert instance.media == [] + + +def test_twiliomessage_invalid_media_count(): + instance = TwilioMessage( + { + "From": "+1234567890", + "To": "+0987654321", + "Body": "Hello, world!", + "NumMedia": "0", + "MediaUrl0": "http://example.com/media1.jpg", + "MediaUrl1": "http://example.com/media2.jpg", + } + ) + + assert instance.media == [] + +def test_twiliomessage_invalid_media_count_extra(): + instance = TwilioMessage( + { + "From": "+1234567890", + "To": "+0987654321", + "Body": "Hello, world!", + "NumMedia": "5", + "MediaUrl0": "http://example.com/media1.jpg", + "MediaUrl1": "http://example.com/media2.jpg", + } + ) + + assert instance.media == [ + "http://example.com/media1.jpg", + "http://example.com/media2.jpg", + None, + None, + None, + ] diff --git a/uv.lock b/uv.lock index 26de6a1..2c60cf8 100644 --- a/uv.lock +++ b/uv.lock @@ -72,18 +72,30 @@ wheels = [ ] [[package]] -name = "apscheduler" -version = "3.6.3" +name = "anyio" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytz" }, - { name = "setuptools" }, - { name = "six" }, - { name = "tzlocal" }, + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/3d/f65972547c5aa533276ada2bea3c2ef51bb4c4de55b67a66129c111b89ad/APScheduler-3.6.3.tar.gz", hash = "sha256:3bb5229eed6fbbdafc13ce962712ae66e175aa214c69bed35a06bffcf0c5e244", size = 96309, upload-time = "2019-11-05T07:51:50.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/34/9ef20ed473c4fd2c3df54ef77a27ae3fc7500b16b192add4720cab8b2c09/APScheduler-3.6.3-py2.py3-none-any.whl", hash = "sha256:e8b1ecdb4c7cb2818913f766d5898183c7cb8936680710a4d3a966e02262e526", size = 58881, upload-time = "2019-11-05T07:51:48.621Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "asgiref" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" }, ] [[package]] @@ -113,15 +125,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] -[[package]] -name = "cachetools" -version = "4.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ba/619250fa6bc11ce6aa4de0604d45843090a53cd7d10d7253b89669313370/cachetools-4.2.2.tar.gz", hash = "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff", size = 23682, upload-time = "2021-04-27T21:19:57.252Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/28/c4f5796c67ad06bb91d98d543a5e01805c1ff065e08871f78e52d2a331ad/cachetools-4.2.2-py3-none-any.whl", hash = "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001", size = 11998, upload-time = "2021-04-27T21:19:55.559Z" }, -] - [[package]] name = "certifi" version = "2025.8.3" @@ -202,6 +205,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" }, ] +[package.optional-dependencies] +async = [ + { name = "asgiref" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -228,6 +236,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -345,6 +390,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prometheus-async" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/38/70567994cce643dcd1845d52e8870d91e24b69f49f59381ed523c06d0650/prometheus_async-25.1.0.tar.gz", hash = "sha256:968e29b5255af57bc8146ade132e7aa0a21fe2b4d95ffefc7c732e9c3fbbb1dd", size = 36469, upload-time = "2025-02-08T10:24:20.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/45/ce7e7c879e7dbb1713f29a666e40285d3b25f7c431fa2c7a771aadc9183a/prometheus_async-25.1.0-py3-none-any.whl", hash = "sha256:4dfa4d85fbbf3fc052a87c7a221a68d4a1d6bd39d31723ac935304dacaedaf69", size = 18253, upload-time = "2025-02-08T10:24:17.23Z" }, +] + [[package]] name = "prometheus-client" version = "0.22.1" @@ -455,27 +514,14 @@ wheels = [ [[package]] name = "python-telegram-bot" -version = "13.15" +version = "22.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "apscheduler" }, - { name = "cachetools" }, - { name = "certifi" }, - { name = "pytz" }, - { name = "tornado" }, + { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/bc/89cd44af162c3f5bf1bd50f4beb9f47353f027552e290899b55723746509/python-telegram-bot-13.15.tar.gz", hash = "sha256:b4047606b8081b62bbd6aa361f7ca1efe87fa8f1881ec9d932d35844bf57a154", size = 357762, upload-time = "2022-12-06T10:01:48.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/fc/0196e0d7ad247011a560788db204e0a28d76ab75b3d7c7131878f8fb5a06/python_telegram_bot-22.3.tar.gz", hash = "sha256:513d5ab9db96dcf25272dad0a726555e80edf60d09246a7d0d425b77115f5440", size = 1464513, upload-time = "2025-07-20T20:03:09.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/db/29356a2f43052af470993fdf2083ca8bf28da4586da836aa609de2d86a06/python_telegram_bot-13.15-py3-none-any.whl", hash = "sha256:06780c258d3f2a3c6c79a7aeb45714f4cd1dd6275941b7dc4628bba64fddd465", size = 519214, upload-time = "2022-12-06T10:01:43.082Z" }, -] - -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/e5/54/0955bd46a1e046169500e129c7883664b6675d580074d68823485e4d5de1/python_telegram_bot-22.3-py3-none-any.whl", hash = "sha256:88fab2d1652dbfd5379552e8b904d86173c524fdb9270d3a8685f599ffe0299f", size = 717115, upload-time = "2025-07-20T20:03:07.261Z" }, ] [[package]] @@ -519,34 +565,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/fd/669816bc6b5b93b9586f3c1d87cd6bc05028470b3ecfebb5938252c47a35/ruff-0.12.9-py3-none-win_arm64.whl", hash = "sha256:63c8c819739d86b96d500cce885956a1a48ab056bbcbc61b747ad494b2485089", size = 11949623, upload-time = "2025-08-14T16:08:52.233Z" }, ] -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "smsbot" -version = "0.0.7" +version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "flask" }, + { name = "flask", extra = ["async"] }, + { name = "prometheus-async" }, { name = "prometheus-client" }, { name = "python-telegram-bot" }, { name = "twilio" }, - { name = "waitress" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -562,11 +591,12 @@ github = [ [package.metadata] requires-dist = [ - { name = "flask", specifier = ">=3.1.1" }, + { name = "flask", extras = ["async"], specifier = ">=3.1.1" }, + { name = "prometheus-async", specifier = ">=25.1.0" }, { name = "prometheus-client", specifier = ">=0.22.1" }, - { name = "python-telegram-bot", specifier = "<20" }, + { name = "python-telegram-bot", specifier = ">=22.3" }, { name = "twilio", specifier = ">=9.7.0" }, - { name = "waitress", specifier = ">=3.0.2" }, + { name = "uvicorn", specifier = ">=0.35.0" }, ] [package.metadata.requires-dev] @@ -578,6 +608,15 @@ dev = [ ] github = [{ name = "pytest-github-actions-annotate-failures", specifier = ">=0.3.0" }] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -587,22 +626,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] -[[package]] -name = "tornado" -version = "6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/44/cc9590db23758ee7906d40cacff06c02a21c2a6166602e095a56cbf2f6f6/tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791", size = 497359, upload-time = "2020-10-30T20:17:51.882Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/e1/ea4aa33216212beec8a20d134a3020648991a35f5d3b68a238654664b872/tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5", size = 416573, upload-time = "2020-10-30T19:08:05.379Z" }, - { url = "https://files.pythonhosted.org/packages/33/6a/94ca5763b12a0fb784131d5d29ab9ebdc220d4050211ecad662393177840/tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe", size = 426978, upload-time = "2020-10-30T22:53:20.351Z" }, - { url = "https://files.pythonhosted.org/packages/ff/75/c2d09f2e25834417f234cbd5b442b2bb8d6ef01009fe5936f24cc1ef66bb/tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea", size = 427233, upload-time = "2020-10-30T22:53:21.246Z" }, - { url = "https://files.pythonhosted.org/packages/00/22/cf57088b1b3ef17cb7eeddb256269c4309fc5a3fdb3a2b0ad535ed87c251/tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2", size = 426979, upload-time = "2020-10-30T22:53:22.714Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b8/b2091d26482993f925d098b451ab5217a4565c56be4db2b67de6cf4921e4/tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0", size = 427236, upload-time = "2020-10-30T22:53:24.023Z" }, - { url = "https://files.pythonhosted.org/packages/70/bb/1f3726d36c3f6a78304c7dc0ac6ca20739eaaf55d332b39f4715e048c48d/tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd", size = 427701, upload-time = "2020-10-30T19:05:00.827Z" }, - { url = "https://files.pythonhosted.org/packages/49/47/dd6ba18fce97a801632245479580e8e5081e629518caf4a82b6ff6607f40/tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c", size = 421590, upload-time = "2020-10-30T20:22:34.675Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2d/4050006dd16f1cc4b8f3a83437b768dc849def37508aaf59b42a8a5907e4/tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4", size = 422133, upload-time = "2020-10-30T20:22:35.58Z" }, -] - [[package]] name = "twilio" version = "9.7.0" @@ -627,27 +650,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] -[[package]] -name = "tzdata" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - [[package]] name = "urllib3" version = "2.5.0" @@ -658,12 +660,17 @@ wheels = [ ] [[package]] -name = "waitress" -version = "3.0.2" +name = "uvicorn" +version = "0.35.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] [[package]] @@ -678,6 +685,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/be/be9b3b0a461ee3e30278706f3f3759b9b69afeedef7fe686036286c04ac6/wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc", size = 53485, upload-time = "2025-08-12T05:51:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a8/8f61d6b8f526efc8c10e12bf80b4206099fea78ade70427846a37bc9cbea/wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9", size = 38675, upload-time = "2025-08-12T05:51:42.885Z" }, + { url = "https://files.pythonhosted.org/packages/48/f1/23950c29a25637b74b322f9e425a17cc01a478f6afb35138ecb697f9558d/wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d", size = 38956, upload-time = "2025-08-12T05:52:03.149Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/dd0791943613885f62619f18ee6107e6133237a6b6ed8a9ecfac339d0b4f/wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a", size = 81745, upload-time = "2025-08-12T05:52:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/bb2d19bd1a614cc4f438abac13ae26c57186197920432d2a915183b15a8b/wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139", size = 82833, upload-time = "2025-08-12T05:52:27.738Z" }, + { url = "https://files.pythonhosted.org/packages/8d/eb/66579aea6ad36f07617fedca8e282e49c7c9bab64c63b446cfe4f7f47a49/wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df", size = 81889, upload-time = "2025-08-12T05:52:29.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/9c/a56b5ac0e2473bdc3fb11b22dd69ff423154d63861cf77911cdde5e38fd2/wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b", size = 81344, upload-time = "2025-08-12T05:52:50.869Z" }, + { url = "https://files.pythonhosted.org/packages/93/4c/9bd735c42641d81cb58d7bfb142c58f95c833962d15113026705add41a07/wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81", size = 36462, upload-time = "2025-08-12T05:53:19.623Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ea/0b72f29cb5ebc16eb55c57dc0c98e5de76fc97f435fd407f7d409459c0a6/wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f", size = 38740, upload-time = "2025-08-12T05:53:18.271Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8b/9eae65fb92321e38dbfec7719b87d840a4b92fde83fd1bbf238c5488d055/wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f", size = 36806, upload-time = "2025-08-12T05:52:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "yarl" version = "1.20.1"