From cd84781640b6a149dafa05f0f8b6b02202f289e5 Mon Sep 17 00:00:00 2001 From: "Nyeki.py" <84998222+Nekidev@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:11:22 -0300 Subject: [PATCH] feat(bot): :tada: Create a redis client from scratch. --- dev/compose.yml | 9 - k8s/redis.yml | 43 -- payphone/bot.py | 7 +- payphone/cogs/calls/cog.py | 114 ++--- payphone/cogs/calls/embeds.py | 6 +- procs/flush_messages.py | 10 +- pyproject.toml | 16 +- redis/__init__.py | 826 ++++++++++++++++++++++++++++++++++ uv.lock | 105 ++--- 9 files changed, 937 insertions(+), 199 deletions(-) create mode 100644 redis/__init__.py diff --git a/dev/compose.yml b/dev/compose.yml index ebc0a9c..99a5379 100644 --- a/dev/compose.yml +++ b/dev/compose.yml @@ -5,15 +5,6 @@ services: image: redis:latest ports: - 6379:6379 - - serverless-redis-http: - ports: - - '8079:80' - image: hiett/serverless-redis-http:latest - environment: - SRH_MODE: env - SRH_TOKEN: payphone - SRH_CONNECTION_STRING: 'redis://redis:6379' postgresql: image: postgres:latest diff --git a/k8s/redis.yml b/k8s/redis.yml index 01c5bb1..ef14cdb 100644 --- a/k8s/redis.yml +++ b/k8s/redis.yml @@ -33,46 +33,3 @@ spec: port: 6379 targetPort: 6379 type: ClusterIP - ---- -# serverless-redis-http Pod -apiVersion: v1 -kind: Pod -metadata: - name: serverless-redis - labels: - app: serverless-redis -spec: - containers: - - name: serverless-redis - image: hiett/serverless-redis-http:latest - ports: - - containerPort: 80 # Internal container port - env: - - name: SRH_MODE - value: "env" - - name: SRH_TOKEN - value: "payphone" - - name: SRH_CONNECTION_STRING - value: "redis://redis:6379" - resources: - requests: - memory: "1Gi" - cpu: "1" - limits: - memory: "2Gi" - cpu: "1" - ---- -# serverless-redis-http Service -apiVersion: v1 -kind: Service -metadata: - name: serverless-redis -spec: - type: ClusterIP - ports: - - port: 8079 # Exposes port 8079 for internal cluster access - targetPort: 80 # Routes to container port 80 - selector: - app: serverless-redis diff --git a/payphone/bot.py b/payphone/bot.py index 33f3718..b58fa0e 100644 --- a/payphone/bot.py +++ b/payphone/bot.py @@ -11,7 +11,7 @@ from discord.ext.commands import errors from tortoise import expressions, transactions, exceptions as tortoise_exc -import upstash_redis.asyncio as aredis +from redis import Redis import asyncpg.exceptions @@ -29,9 +29,8 @@ from payphone.core import ( class Payphone(commands.AutoShardedBot): log = logging.logger - redis: aredis.Redis = aredis.Redis( - url="http://localhost:8079" if config.IS_DEBUG else "http://serverless-redis:8079", - token="payphone" + redis: Redis = Redis( + url="redis://localhost" if config.IS_DEBUG else "redis://redis", ) def __init__(self) -> None: diff --git a/payphone/cogs/calls/cog.py b/payphone/cogs/calls/cog.py index f44e2bd..7ba9fa7 100644 --- a/payphone/cogs/calls/cog.py +++ b/payphone/cogs/calls/cog.py @@ -103,8 +103,8 @@ class Calls(base.Cog): if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id: last_calls_other_channels_ids.add(call_rs.channel_id) - other_channel_id: int = await self.bot.redis.spop( - constants.CHANNELS_SEARCH_QUEUE + other_channel_id: int = next( + iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), None ) i = 0 @@ -127,8 +127,9 @@ class Calls(base.Cog): constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id) ) - other_channel_id = await self.bot.redis.spop( - constants.CHANNELS_SEARCH_QUEUE + other_channel_id = next( + iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), + None, ) if other_channel_id is None: @@ -293,8 +294,8 @@ class Calls(base.Cog): if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id: last_calls_other_channels_ids.add(call_rs.channel_id) - other_channel_id: int = await self.bot.redis.spop( - constants.CHANNELS_SEARCH_QUEUE + other_channel_id: int = next( + iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), None ) i = 0 @@ -317,8 +318,9 @@ class Calls(base.Cog): constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id) ) - other_channel_id = await self.bot.redis.spop( - constants.CHANNELS_SEARCH_QUEUE + other_channel_id = next( + iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), + None, ) if other_channel_id is None: @@ -762,45 +764,50 @@ class Calls(base.Cog): pipe.hset( constants.CALL_MESSAGES_TEMPLATE % call.id, - message.id, - orjson.dumps( - { - "id": message.id, - "call_id": call.id, - "channel_id": channel.id, - "user_id": user.id, - "profile_id": profile.id, - "mask": call_rs.mask if call_rs.is_masked else None, - "propagation_id": webhook_message.id if webhook_message else None, - "propagation_channel_id": ( - other_channel.id if webhook_message else None - ), - "replying_to_id": replying_to_id, - "created_at": datetime.now(timezone.utc), - } - ).decode(), + { + message.id: orjson.dumps( + { + "id": message.id, + "call_id": call.id, + "channel_id": channel.id, + "user_id": user.id, + "profile_id": profile.id, + "mask": call_rs.mask if call_rs.is_masked else None, + "propagation_id": ( + webhook_message.id if webhook_message else None + ), + "propagation_channel_id": ( + other_channel.id if webhook_message else None + ), + "replying_to_id": replying_to_id, + "created_at": datetime.now(timezone.utc), + } + ).decode() + }, ) pipe.hset( constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call.id, - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE % (message.id, 1), - orjson.dumps( - { - "message_id": message.id, - "content": cleaned_content, - "was_propagated": not bool(blocked_word), - "created_at": datetime.now(timezone.utc), - } - ).decode(), + { + constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE + % (message.id, 1): orjson.dumps( + { + "message_id": message.id, + "content": cleaned_content, + "was_propagated": not bool(blocked_word), + "created_at": datetime.now(timezone.utc), + } + ).decode() + }, ) pipe.hset( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call.id, message.id, 1 + constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call.id, + {message.id: 1}, ) if webhook_message is not None: pipe.hset( constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call.id, - webhook_message.id, - message.id, + {webhook_message.id: message.id}, ) if replying_to_id is not None: @@ -811,18 +818,16 @@ class Calls(base.Cog): if other_replies is None: pipe.hset( constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, - replying_to_id, - message.id, + {replying_to_id: message.id}, ) else: pipe.hset( constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, - replying_to_id, - other_replies + f",{message.id}", + {replying_to_id: other_replies + f",{message.id}"}, ) - await pipe.exec() + await pipe.execute() @commands.Cog.listener() @commands.guild_only() @@ -862,8 +867,7 @@ class Calls(base.Cog): await self.bot.redis.hset( constants.CALL_MESSAGES_TEMPLATE % channel.call_id, - message.id, - orjson.dumps(message_dict).decode(), + {message.id: orjson.dumps(message_dict).decode()}, ) other_channel = await models.Channel.get_or_none( @@ -975,18 +979,20 @@ class Calls(base.Cog): ) pipe.hset( constants.CALL_MESSAGE_CONTENTS_TEMPLATE % channel.call_id, - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE % (message.id, iteration + 1), - orjson.dumps( - { - "message_id": message.id, - "content": cleaned_content, - "was_propagated": not bool(blocked_word), - "created_at": datetime.now(timezone.utc), - } - ).decode(), + { + constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE + % (message.id, iteration + 1): orjson.dumps( + { + "message_id": message.id, + "content": cleaned_content, + "was_propagated": not bool(blocked_word), + "created_at": datetime.now(timezone.utc), + } + ).decode() + }, ) - await pipe.exec() + await pipe.execute() async with aiohttp.ClientSession() as session: webhook = other_channel.webhook(session) diff --git a/payphone/cogs/calls/embeds.py b/payphone/cogs/calls/embeds.py index 6bcd044..6828997 100644 --- a/payphone/cogs/calls/embeds.py +++ b/payphone/cogs/calls/embeds.py @@ -2,19 +2,19 @@ import discord import orjson -import upstash_redis.asyncio as aredis +from redis import Redis from payphone.db import models from payphone.core import constants, formatting async def ReplyEmbed( - redis: aredis.Redis, reference: discord.MessageReference, call_id: int + redis: Redis, reference: discord.MessageReference, call_id: int ) -> tuple[discord.Embed | None, models.Message | None]: """Generates the embed sent on calls to represent replies. Args: - redis (redis.asyncio.Redis): An instance of a Redis connection handler. + redis (redis.Redis): An instance of a Redis connection handler. reference (discord.MessageReference): The message reference. call_id (int): The ID of the call the message was sent in. diff --git a/procs/flush_messages.py b/procs/flush_messages.py index b867420..24b3519 100644 --- a/procs/flush_messages.py +++ b/procs/flush_messages.py @@ -9,20 +9,20 @@ async def flush(): import asyncpg.exceptions - import upstash_redis.asyncio as aredis + import redis from payphone.db import models from payphone.core import constants, utils, config - redis = aredis.Redis(host="localhost" if config.IS_DEBUG else "redis") + redis = redis.Redis("localhost" if config.IS_DEBUG else "redis") pipe = redis.pipeline() pipe.smembers(constants.CALL_IDS_TO_FLUSH) pipe.delete(constants.CALL_IDS_TO_FLUSH) - call_ids, _ = await pipe.exec() + call_ids, _ = await pipe.execute() # Redis returns bytes. call_ids = [int(call_id) for call_id in call_ids] @@ -40,7 +40,7 @@ async def flush(): pipe.hgetall(constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call_id) for call_id, (messages, message_contents) in zip( - call_ids, utils.chunks(await pipe.exec(), 2) + call_ids, utils.chunks(await pipe.execute(), 2) ): message_rows: list[models.Message] = [] @@ -68,7 +68,7 @@ async def flush(): constants.CALL_IS_ACTIVE % call_id, ) - await pipe.exec() + await pipe.execute() for _ in range(5): try: diff --git a/pyproject.toml b/pyproject.toml index 2367dd3..4bc0c6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,22 @@ [project] name = "payphone" -version = "0.12.0" +version = "0.13.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" -dependencies = [ "aerich==0.7.1", "aiohttp>=3.10.10", "click>=8.1.7", "colorthief>=0.2.1", "httpx>=0.27.2", "orjson>=3.10.7", "py-cord>=2.6.1", "python-dotenv>=1.0.1", "rich>=13.9.3", "tortoise-orm[asyncpg]>=0.21.7", "upstash-redis>=1.2.0",] +dependencies = [ + "aerich==0.7.1", + "aiohttp>=3.10.10", + "click>=8.1.7", + "colorthief>=0.2.1", + "hiredis>=3.0.0", + "httpx>=0.27.2", + "orjson>=3.10.7", + "py-cord>=2.6.1", + "python-dotenv>=1.0.1", + "rich>=13.9.3", + "tortoise-orm[asyncpg]>=0.21.7", +] [tool.aerich] tortoise_orm = "payphone.core.config.TORTOISE_ORM" diff --git a/redis/__init__.py b/redis/__init__.py new file mode 100644 index 0000000..71b755d --- /dev/null +++ b/redis/__init__.py @@ -0,0 +1,826 @@ +from typing import TypeAlias, Awaitable, AsyncGenerator + +from urllib import parse + +from datetime import datetime, timedelta + +import asyncio + +import hiredis + + +Resp3Repr: TypeAlias = str | int | bytes | bytearray | float | bool | None + + +class _Ellipsis: + """Represents an incomplete response of Redis.""" + + +def _encode( + arg: ( + Resp3Repr + | list[Resp3Repr] + | tuple[Resp3Repr] + | dict[Resp3Repr, Resp3Repr] + | set[Resp3Repr] + ) +) -> bytes: + """Encode a value into its RESP3 representation. + + This function supports str (converted to bytes), int, bytes, bytearray, float, bool, and None + as basic types. It also supports lists (arrays), dicts (maps), and sets. + + Args: + arg (Resp3Repr | list[Resp3Repr] | tuple[Resp3Repr] | dict[Resp3Repr, Resp3Repr] | set[Resp3Repr]): + The argument to encode. + + Raises: + ValueError: The value provided is invalid or not encodable. + + Returns: + bytes: The encoded representation of the value. + """ + + if isinstance(arg, (str, int)): + arg = str(arg).encode() + + if isinstance(arg, (bytes, bytearray)): + return f"${len(arg)}\r\n".encode() + bytes(arg) + b"\r\n" + + elif isinstance(arg, int): + return f":{arg}\r\n".encode() + + elif isinstance(arg, type(None)): + return b"_\r\n" + + elif isinstance(arg, float): + result = f",{arg}\r\n".encode() + + elif isinstance(arg, bool): + if arg: + return b"#t\r\n" + + return b"#f\r\n" + + elif isinstance(arg, (list, tuple)): + return f"*{len(arg)}\r\n".encode() + b"".join([_encode(i) for i in arg]) + + elif isinstance(arg, dict): + result = f"%{len(arg.keys())}\r\n".encode() + + for key, value in arg.items(): + result += _encode(key) + _encode(value) + + return result + + elif isinstance(arg, set): + result = f"~{len(arg)}\r\n".encode() + + for item in arg: + result += _encode(item) + + return result + + raise ValueError(f"Value of type {type(arg)} is not encodable into RESP3.") + + +def _get_secs(time: int | timedelta) -> int: + """Converts an integer or timedelta into an amount of seconds. + + Args: + time (int | timedelta): The number or timedelta to convert. + + Returns: + int: The amount of seconds. + """ + + if isinstance(time, timedelta): + return int(time.total_seconds()) + + return time + + +def _get_millis(time: int | timedelta) -> int: + """Converts an integer or timedelta into an amount of milliseconds. + + Args: + time (int | timedelta): The number or timedelta to convert. + + Returns: + int: The amount of milliseconds. + """ + + if isinstance(time, timedelta): + return int(time.total_seconds() * 1000) + + return time + + +def _get_timestamp_secs(dtime: datetime | int) -> int: + """Converts a datetime or timestamp into it's seconds timestamp. + + Args: + dtime (datetime | int): The datetime object to convert or an integer value which will be + returned as-is. + + Returns: + int: The seconds timestamp. + """ + + if isinstance(dtime, datetime): + return int(dtime.timestamp()) + + return dtime + + +def _get_timestamp_millis(dtime: datetime | int) -> int: + """Converts a datetime or timestamp into it's milliseconds timestamp. + + Args: + dtime (datetime | int): The datetime object to convert or an integer value which will be + returned as-is. + + Returns: + int: The milliseconds timestamp. + """ + + if isinstance(dtime, datetime): + return int(dtime.timestamp() * 1000) + + return dtime + + +class RedisError(Exception): + """Raised whenever an error is raised from this library.""" + + +class FatalRedisError(RedisError): + """Raised whenever an error occurs that does not allow the connection to stay alive.""" + + +class Runnable: + """The base class for all Redis commands. + + This class is inherited by Connection, Pipeline, and Transaction to implement their own + functionality. + """ + + _client: "Redis" + + def __init__(self, client: "Redis") -> None: + """Initializes the runner with the client's configuration. + + Args: + client (Redis): The client that initialized the runner. + """ + + self._client = client + + def command(self, *args: tuple[Resp3Repr]) -> Resp3Repr | Awaitable[Resp3Repr]: + """Runs a command taking it as a list of arguments. + + For example: + ``` + await Runnable.command("SET", "key", "value") + ``` + + This method is not implemented by default, so you need a Connection, a Pipeline, or a + Transaction to use it. + + Returns: + Resp3Repr | Awaitable[Resp3Repr]: The value(s) returned by the execution of the + command. + """ + + raise NotImplementedError( + "This method needs to be implemented by a subclass of Runner." + ) + + def ping(self, arg: str = "PONG") -> Resp3Repr: + """Returns PONG if no argument is provided, otherwise return a copy of the argument as a + bulk. + + Args: + arg (str): The argument to return by the command. + + Returns: + Resp3Repr: The response of the command. + """ + + return self.command("PING", str(arg)) + + +class RunnableGenericMixin: + """Mixin containing all Redis generic commands.""" + + def delete(self, *keys: tuple[str]) -> None: + """Deletes one or more keys.""" + + return self.command("DEL", *[str(key) for key in keys]) + + def exists(self, *keys: tuple[str]) -> int: + """Returns if key exists. + + The user should be aware that if the same existing key is mentioned in the arguments + multiple times, it will be counted multiple times. So if somekey exists, `EXISTS somekey + somekey` will return 2. + + Args: + *keys (tuple[str]): The keys to check for existance. + + Returns: + int: The amount of keys that exist. + """ + + return self.command("EXISTS", *[str(key) for key in keys]) + + +class RunnableHashMixin: + """Mixin containing all Redis hash commands.""" + + def hget(self, key: str, field: str) -> str | None: + """Returns the value associated with field in the hash stored at key. + + Args: + key (str): The key of the hash. + field (str): The field of the hash to get. + + Returns: + str | None: The value of the field, or None if it does not exist. + """ + + return self.command("HGET", str(key), str(field)) + + def hgetall(self, key: str) -> dict[Resp3Repr, Resp3Repr]: + """Returns all fields and values of the hash stored at key. + + Args: + key (str): The key to get the fields and values from. + + Returns: + dict[Resp3Repr, Resp3Repr]: A dict containing all the fields mapped to their respective + values. + """ + + return self.command("HGETALL", str(key)) + + def hincrby(self, key: str, field: str, increment: int) -> int: + """Increments the number stored at field in the hash stored at key by increment. + + If key does not exist, a new key holding a hash is created. If field does not exist the + value is set to 0 before the operation is performed. The range of values supported by + HINCRBY is limited to 64 bit signed integers. + + Args: + key (str): The key of the hash whose field to increment. + field (str): The field to increment. + increment (int): The amount to increment by. + + Returns: + int: The amount returned + """ + + return self.command("HINCRBY", str(key), str(field), str(increment)) + + def hset(self, key: str, mapping: dict[Resp3Repr, Resp3Repr]) -> int: + """Sets the specified fields to their respective values in the hash stored at key. + + This command overwrites the values of specified fields that exist in the hash. If key + doesn't exist, a new key holding a hash is created. + + Args: + key (str): The key to set the values for. + mapping (dict[Resp3Repr, Resp3Repr]): The fields mapped to their values. + + Returns: + int: The number of fields that were added. + """ + + items = [] + + for k, v in mapping.items(): + items.append(k) + items.append(v) + + return self.command("HSET", str(key), *items) + + +class RunnableSetMixin: + """Mixin containing all Redis set commands.""" + + def sadd(self, key: str, *values: tuple[str]) -> int: + """Adds the specified members to the set stored at key. + + Specified members that are already a member of this set are ignored. If key does not exist, + a new set is created before adding the specified members. An error is raised when the value + stored at key is not a set. + + Args: + key (str): The key to add the values to. + *values (tuple[str]): The values to add to the set. + + Returns: + int: The amount of new values that were added. + """ + + return self.command("SADD", str(key), *values) + + def scard(self, key: str) -> int: + """Returns the set cardinality (number of elements) of the set stored at key. + + Args: + key (str): The key of the set to get the cardinality from. + + Returns: + int: The number of elements, or 0 if it does not exist. + """ + + return self.command("SCARD", str(key)) + + def smembers(self, key: str) -> set[str]: + """Returns all the members of the set value stored at key. + + Args: + key (str): The key of the set to get the members from. + + Returns: + set[str]: The members of the set. + """ + + return self.command("SMEMBERS", str(key)) + + def sismember(self, key: str, value: str) -> bool: + """Returns whether the value is a member of the set. + + Args: + key (str): The key of the set. + value (str): The value to check whether it is a member of the set. + + Returns: + bool: Whether the value is a member of the set. + """ + + return self.command("SISMEMBER", str(key), str(value)) + + def spop(self, key: str, count: int = 1) -> str | list[str]: + """Removes and returns one or more random members from the set value store at key.""" + + return self.command("SPOP", str(key), count) + + def srem(self, key: str, *values: tuple[str]) -> int: + """Removes the specified members from the set stored at key. + + Specified members that are not a member of this set are ignored. If key does not exist, + it is treated as an empty set and this command returns 0. An error is returned when the + value stored at key is not a set. + + Args: + key (str): The key of the set. + *values (tuple[str]): The values to remove from the set. + + Returns: + int: The amount of values that were removed from the set. + """ + + return self.command("SREM", str(key), *values) + + +class RunnableStringMixin: + """Mixin containing all Redis string commands.""" + + def append(self, key: str, value: str) -> int: + """Appends a string to an existing key, or creates a new one if it does not exist. + + If key already exists and is a string, this command appends the value at the end of the + string. If key does not exist it is created and set as an empty string, so APPEND will be + similar to SET in this special case. + + Args: + key (str): The key to append to. + value (str): The string to append. + + Returns: + int: The new length of the value. + """ + + return self.command("APPEND", str(key), str(value)) + + def decr(self, key: str) -> int: + """Decrements the number stored at key by one. + + If the key does not exist, it is set to 0 before performing the operation. An error is + returned if the key contains a value of the wrong type or contains a string that can not be + represented as integer. This operation is limited to 64 bit signed integers. + + Args: + key (str): The key to decrement. + + Returns: + int: The decremented value. + """ + + return self.command("DECR", str(key)) + + def decrby(self, key: str, decrement: int) -> int: + """Reduces the value stored at the specified key by the specified decrement. + + If the key does not exist, it is initialized with a value of 0 before performing the + operation. If the key's value is not of the correct type or cannot be represented as an + integer, an error is returned. This operation is limited to 64-bit signed integers. + + Args: + key (str): The key to decrement. + decrement (int): The amount to decrement by. + + Returns: + int: The decremented value. + """ + + return self.command("DECRBY", str(key), decrement) + + def get(self, key: str) -> str | None: + """Returns the string value of a key. + + If the key does not exist None is returned. An error is returned if the value stored at key + is not a string, because GET only handles string values. + + Args: + key (str): The key to get the value from. + + Returns: + str | None: The value of the key, of None if it does not exist. + """ + + return self.command("GET", str(key)) + + def getdel(self, key: str) -> str | None: + """Returns the value of key and delete the key. + + This command is similar to GET, except for the fact that it also deletes the key on success + (if and only if the key's value type is a string). + + Args: + key (str): The key to get and delete. + + Returns: + str | None: The value of the key. + """ + + return self.command("GETDEL", str(key)) + + def set( + self, + key: str, + value: str, + nx: bool = False, + xx: bool = False, + get: bool = False, + ex: timedelta | int | None = None, + px: timedelta | int | None = None, + exat: datetime | int | None = None, + pxat: datetime | int | None = None, + keepttl: bool = False, + ) -> str | None: + """Set key to hold the string value. + + If key already holds a value, it is overwritten, regardless of its type. Any previous time + to live associated with the key is discarded on successful SET operation. + + Args: + key (str): The key to set. + value (str): The value to set to the specified key. + nx (bool, optional): Only set the key if it does not already exist. Defaults to False. + xx (bool, optional): Only set the key if it already exists. Defaults to False. + get (bool, optional): Return the old string stored at key, or None if key did not + exist. An error is returned and SET aborted if the value stored at key is not a + string. Defaults to False. + ex (timedelta | int | None, optional): Set the specified expire time, in seconds (a positive + integer). Defaults to None. + px (timedelta | int | None, optional): Set the specified expire time, in milliseconds (a positive + integer). Defaults to None. + exat (datetime | int | None, optional): Set the specified time at which the key will + expire, in seconds (a positive integer) or a datetime object. Defaults to None. + pxat (datetime | int | None, optional): Set the specified time at which the key will + expire, in milliseconds (a positive integer) or a datetime object. Defaults to None. + keepttl (bool, optional): Retain the time to live associated with the key. Defaults to + False. + + Returns: + str | None: The old key in case `get` was set, or None. + """ + + if nx and xx: + raise ValueError("Only one of XX or NX can be set, but not both.") + + if len([i for i in (ex, px, exat, pxat, keepttl) if i]) > 1: + raise ValueError( + "Only one of EX, PX, EXAT, PXAT, or KEEPTTL can be set, but not multiple of them." + ) + + command = ["SET", str(key), str(value)] + + if nx: + command += ["NX"] + + elif xx: + command += ["XX"] + + if get: + command += ["GET"] + + if ex: + command += ["EX", str(_get_secs(ex))] + + elif px: + command += ["PX", str(_get_millis(px))] + + elif exat: + command += ["EXAT", str(_get_timestamp_secs(exat))] + + elif pxat: + command += ["PXAT", str(_get_timestamp_millis(pxat))] + + elif keepttl: + command += ["KEEPTTL"] + + return self.command(*command) + + +class FullRunnable( + Runnable, + RunnableGenericMixin, + RunnableHashMixin, + RunnableSetMixin, + RunnableStringMixin, +): + """A runnable containing all the command mixins.""" + + +class Connection(FullRunnable): + _reader: asyncio.StreamReader + _writer: asyncio.StreamWriter + _parser: hiredis.Reader + _lock: asyncio.Lock + + def __init__( + self, + client: "Redis", + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + parser: hiredis.Reader = hiredis.Reader(notEnoughData=_Ellipsis), + ) -> None: + """Initializes the connection object. + + Args: + client (Redis): The client that initialized the connection. + reader (asyncio.StreamReader): The socket stream reader. + writer (asyncio.StreamWriter): The socket stream writer. + parser (hiredis.Reader, optional): The RESP3 parser. Defaults to a basic implementation + if not set. + """ + + self._client = client + self._reader = reader + self._writer = writer + self._parser = parser + self._lock = asyncio.Lock() + + super().__init__(client) + + def release(self) -> None: + """Returns the connection back to the pool.""" + + self._client._pool.append(self) + + def destroy(self) -> None: + """Closes the connection and frees it's space in the client's pool.""" + + self._client._open_connections -= 1 + + self._writer.close() + + async def __aenter__(self) -> "Connection": + return self + + async def __aexit__(self, *args) -> None: + self.release() + + async def command(self, *args: list[Resp3Repr]) -> Resp3Repr: + """Executes a command and returns it's response. + + Raises: + hiredis.ReplyError: Any errors returned by Redis. + """ + + args = list(args) + + command = _encode(args) + + async def run(): + async with self._lock: + self._writer.write(command) + await self._writer.drain() + + while True: + self._parser.feed(await self._reader.readuntil(b"\r\n")) + + result = self._parser.gets() + + if result == _Ellipsis: + continue + + elif isinstance(result, hiredis.ReplyError): + raise result + + return result + + try: + return await asyncio.wait_for(run(), self._client._command_timeout) + + except ( + hiredis.ProtocolError, + asyncio.TimeoutError, + ConnectionResetError, + ) as exc: + self.destroy() + + raise FatalRedisError() from exc + + async def hello(self, username: str = None, password: str = None) -> dict: + """Sends the initial HELLO command to the server. + + Args: + username (str, optional): The username to authenticate with. Defaults to None, or + "default" if `password` is set. + password (str, optional): The password to authenticate with. Defaults to None. + + Returns: + dict: The metadata map returned by the server. + """ + + command = ["HELLO", "3"] + + if password is not None: + command += ["AUTH", username or "default", password] + + return await self.command(*command) + + +class Pipeline(FullRunnable): + """A Redis pipeline.""" + + _commands: list[tuple[Resp3Repr]] + + def __init__(self, client: "Redis") -> None: + self._commands = [] + + super().__init__(client) + + def command(self, *args: tuple[Resp3Repr]) -> None: + self._commands.append(args) + + async def execute(self): + """Executes the pipeline. + + Returns: + tuple[Resp3Repr]: The results returned by the execution of the commands. + """ + + command = b"".join([_encode(comm) for comm in self._commands]) + + async with await self._client.connection() as conn: + + async def run(): + async with conn._lock: + conn._writer.write(command) + await conn._writer.drain() + + results = [] + + while True: + conn._parser.feed(await conn._reader.readuntil(b"\r\n")) + + result = conn._parser.gets() + + if result == _Ellipsis: + continue + + elif isinstance(result, hiredis.ReplyError): + raise RedisError( + "An error was returned from the Redis server." + ) from result + + results.append(result) + + if len(results) == len(self._commands): + return results + + try: + return tuple( + await asyncio.wait_for(run(), self._client._command_timeout) + ) + + except ( + hiredis.ProtocolError, + asyncio.TimeoutError, + ConnectionResetError, + ) as exc: + conn.destroy() + + raise FatalRedisError() from exc + + +class Redis(FullRunnable): + _pool: list[Connection] = [] + _open_connections: int = 0 + + def __init__( + self, + url: str = "", + max_connections: int = 100, + connection_timeout: int = 3, + command_timeout: int = 3, + encoding: str = "utf-8", + ) -> None: + """Initializes the client class. + + Args: + url (str): The redis URL to connect to. Defaults to "redis://localhost:6379" + max_connections (int, optional): The maximum amount of connections that can be opened. + Defaults to 100. + connection_timeout (int, optional): The amount of seconds to wait for a connection to + establish before raising a TimeoutError. Defaults to 3. + command_timeout (int, optional): The amount of seconds to wait for a command to execute + before raising a TimeoutError. Defaults to 3. + encoding (str, optional): The encoding with which to decode Redis response values. + """ + + self._client = self + + self._lock = asyncio.Lock() + self._max_connections = max_connections + self._connection_timeout = connection_timeout + self._command_timeout = command_timeout + self._encoding = encoding + + parsed_url = parse.urlparse(url) + + self._host = parsed_url.hostname or "localhost" + self._port = parsed_url.port or 6379 + self._username = parsed_url.username + self._password = parsed_url.password + + async def connection(self) -> Connection: + """Returns an available connection or creates a new one if none are available and the pool + has space remaining. + + Raises: + ConnectionError: If the pool has reached it's max size and no connections are available. + """ + + async with self._lock: + try: + connection = self._pool.pop() + return connection + + except IndexError as exc: + if self._open_connections >= self._max_connections: + raise ConnectionError( + "The pool has reached it's limit of connections and no connections are " + "available at the moment." + ) from exc + + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host=self._host, port=self._port), + self._connection_timeout, + ) + + connection = Connection( + client=self, + reader=reader, + writer=writer, + parser=hiredis.Reader( + notEnoughData=_Ellipsis, encoding=self._encoding + ), + ) + + await connection.hello(username=self._username, password=self._password) + + self._open_connections += 1 + + return connection + + async def command(self, *args: tuple[Resp3Repr]) -> Awaitable[Resp3Repr]: + """Executes a command with a free connection from the pool. + + Returns: + Awaitable[Resp3Repr]: The command's response. + """ + + async with await self.connection() as conn: + return await conn.command(*args) + + def pipeline(self) -> Pipeline: + """Creates a new pipeline.""" + + return Pipeline(self) diff --git a/uv.lock b/uv.lock index a427d3f..eeb9bdc 100644 --- a/uv.lock +++ b/uv.lock @@ -164,45 +164,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, - { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, - { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, - { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, - { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, - { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, - { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, - { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, - { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, - { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, - { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, - { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, - { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, - { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, - { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, - { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, - { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, - { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, - { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, - { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, - { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, - { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, - { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, - { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, - { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, -] - [[package]] name = "click" version = "8.1.7" @@ -293,6 +254,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, ] +[[package]] +name = "hiredis" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/80/740fb0dfa7a42416ce8376490f41dcdb1e5deed9c3739dfe4200fad865a9/hiredis-3.0.0.tar.gz", hash = "sha256:fed8581ae26345dea1f1e0d1a96e05041a727a45e7d8d459164583e23c6ac441", size = 87581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/09/0a3eace00115d8c82a8e7d8e58e60aacec10334f4f1512f09ffbac3252e3/hiredis-3.0.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:484025d2eb8f6348f7876fc5a2ee742f568915039fcb31b478fd5c242bb0fe3a", size = 81540 }, + { url = "https://files.pythonhosted.org/packages/1c/e8/1a7a5ded4fb11e91aafc5ba5518392f22883d54e79c4b47f188fb712ea46/hiredis-3.0.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:fcdb552ffd97151dab8e7bc3ab556dfa1512556b48a367db94b5c20253a35ee1", size = 44814 }, + { url = "https://files.pythonhosted.org/packages/3b/f5/4e055dc9b55484644afb18063f28649cdbd19be4f15bc152bd633dccd6f7/hiredis-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bb6f9fd92f147ba11d338ef5c68af4fd2908739c09e51f186e1d90958c68cc1", size = 42478 }, + { url = "https://files.pythonhosted.org/packages/65/7b/e06f55b9dcdf10cb6b3f08d7917d3080096cd83deaef1bd4927720fbb280/hiredis-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa86bf9a0ed339ec9e8a9a9d0ae4dccd8671625c83f9f9f2640729b15e07fbfd", size = 168303 }, + { url = "https://files.pythonhosted.org/packages/f4/16/081e90137bb896acd9dc2e1e68480cc84d652af4d959e75e52d6ce9dd602/hiredis-3.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e194a0d5df9456995d8f510eab9f529213e7326af6b94770abf8f8b7952ddcaa", size = 179151 }, + { url = "https://files.pythonhosted.org/packages/1e/0f/f5aba1c82977f4b639e5b450c0d8685333f1200cd1972647eb3f4d972e55/hiredis-3.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a1df39d74ec507d79c7a82c8063eee60bf80537cdeee652f576059b9cdd15c", size = 168580 }, + { url = "https://files.pythonhosted.org/packages/60/86/aa24c20f6d3038bf244bc60a2fe8cde61fb3c0d6a82e2bed30b08d55f96c/hiredis-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f91456507427ba36fd81b2ca11053a8e112c775325acc74e993201ea912d63e9", size = 169147 }, + { url = "https://files.pythonhosted.org/packages/6e/03/a4c7a28b6320ef3e36062c1c51e9d66e889c9e09ee7d7ae38b8a2ffdb365/hiredis-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9862db92ef67a8a02e0d5370f07d380e14577ecb281b79720e0d7a89aedb9ee5", size = 164722 }, + { url = "https://files.pythonhosted.org/packages/cd/66/d60106b56ba0ddd9789656d204a577591ff0cd91ab94178bb96c84d0d918/hiredis-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d10fcd9e0eeab835f492832b2a6edb5940e2f1230155f33006a8dfd3bd2c94e4", size = 162561 }, + { url = "https://files.pythonhosted.org/packages/6a/30/f33f2b782096efe9fe6b24c67a4df13b5055d9c859f615a74fb4f18cce41/hiredis-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:48727d7d405d03977d01885f317328dc21d639096308de126c2c4e9950cbd3c9", size = 161388 }, + { url = "https://files.pythonhosted.org/packages/45/02/34d9b151f9ea4655bfe00e0230f7db8fd8a52c7b7bd728efdf1c17655860/hiredis-3.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e0bb6102ebe2efecf8a3292c6660a0e6fac98176af6de67f020bea1c2343717", size = 173561 }, + { url = "https://files.pythonhosted.org/packages/cf/54/68285d208918b6d83e32d872d8dcbf8d479ed2c74b863b836e48a2702a3f/hiredis-3.0.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:df274e3abb4df40f4c7274dd3e587dfbb25691826c948bc98d5fead019dfb001", size = 165914 }, + { url = "https://files.pythonhosted.org/packages/56/4f/5f36865f9f032caf00d603ff9cbde21506d2b1e0e0ce0b5d2ce2851411c9/hiredis-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:034925b5fb514f7b11aac38cd55b3fd7e9d3af23bd6497f3f20aa5b8ba58e232", size = 163968 }, + { url = "https://files.pythonhosted.org/packages/d3/ee/c38693bd1dbce34806ecc3536dc425e87e420030de7018194865511860c2/hiredis-3.0.0-cp312-cp312-win32.whl", hash = "sha256:120f2dda469b28d12ccff7c2230225162e174657b49cf4cd119db525414ae281", size = 20189 }, + { url = "https://files.pythonhosted.org/packages/4e/67/f50b45071bb8652fa9a28a84ee470a02042fb7a096a16f3c08842f2a5c2b/hiredis-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e584fe5f4e6681d8762982be055f1534e0170f6308a7a90f58d737bab12ff6a8", size = 21971 }, +] + [[package]] name = "httpcore" version = "1.0.6" @@ -427,20 +411,20 @@ wheels = [ [[package]] name = "payphone" -version = "0.10.0" +version = "0.13.0" source = { virtual = "." } dependencies = [ { name = "aerich" }, { name = "aiohttp" }, { name = "click" }, { name = "colorthief" }, + { name = "hiredis" }, { name = "httpx" }, { name = "orjson" }, { name = "py-cord" }, { name = "python-dotenv" }, { name = "rich" }, { name = "tortoise-orm", extra = ["asyncpg"] }, - { name = "upstash-redis" }, ] [package.dev-dependencies] @@ -454,13 +438,13 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.10.10" }, { name = "click", specifier = ">=8.1.7" }, { name = "colorthief", specifier = ">=0.2.1" }, + { name = "hiredis", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.27.2" }, { name = "orjson", specifier = ">=3.10.7" }, { name = "py-cord", specifier = ">=2.6.1" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "rich", specifier = ">=13.9.3" }, { name = "tortoise-orm", extras = ["asyncpg"], specifier = ">=0.21.7" }, - { name = "upstash-redis", specifier = ">=1.2.0" }, ] [package.metadata.requires-dev] @@ -642,21 +626,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, ] -[[package]] -name = "requests" -version = "2.32.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, -] - [[package]] name = "rich" version = "13.9.4" @@ -727,28 +696,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] -[[package]] -name = "upstash-redis" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ec/4d137a714a3e1a621fbf39eac88489291bbc43912f9c2f8f4c8258eda5be/upstash_redis-1.2.0.tar.gz", hash = "sha256:8708ef56241832df1ed0dd3c27c19c14ca38dc34df9c5d3fc2c1ba8c2586edc6", size = 32898 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/97/2eaef1e7ffcf846733256aba60cee69ffc86daeaf039877eae6d4ee7416c/upstash_redis-1.2.0-py3-none-any.whl", hash = "sha256:a23db26bafb3706e80dbdcbdbb50bb419ca229247cb47eebfff986c59cfcaabf", size = 35351 }, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, -] - [[package]] name = "yarl" version = "1.17.1"