diff --git a/bot/cogs/config.py b/bot/cogs/config.py index 0dc7964..45084c2 100644 --- a/bot/cogs/config.py +++ b/bot/cogs/config.py @@ -1,9 +1,15 @@ from __future__ import annotations +import datetime +import difflib +from enum import Enum +from pathlib import Path from typing import ( TYPE_CHECKING, Annotated, Any, + AsyncIterator, + Literal, NamedTuple, Optional, Union, @@ -12,24 +18,34 @@ import asyncpg import discord +import humanize import msgspec from async_lru import alru_cache from discord import app_commands -from discord.ext import commands +from discord.ext import commands, menus from libs.tickets.utils import get_cached_thread from libs.utils import GuildContext -from libs.utils.checks import bot_check_permissions, check_permissions +from libs.utils.checks import ( + bot_check_permissions, + check_permissions, + is_manager, +) +from libs.utils.config import OptionsHelp from libs.utils.embeds import CooldownEmbed, Embed from libs.utils.pages import SimplePages +from libs.utils.pages.paginator import RoboPages from libs.utils.prefix import get_prefix +from libs.utils.time import FriendlyTimeResult, UserFriendlyTime if TYPE_CHECKING: from cogs.tickets import Tickets from rodhaj import Rodhaj + UNKNOWN_ERROR_MESSAGE = ( "An unknown error happened. Please contact the dev team for assistance" ) +OPTIONS_FILE = Path(__file__).parents[1] / "locale" / "options.json" class BlocklistTicket(NamedTuple): @@ -100,6 +116,11 @@ def replace(self, blocklist: dict[int, BlocklistEntity]) -> None: self._blocklist = blocklist +class ConfigType(Enum): + TOGGLE = 0 + SET = 1 + + # Msgspec Structs are usually extremely fast compared to slotted classes class GuildConfig(msgspec.Struct): bot: Rodhaj @@ -126,6 +147,53 @@ def ticket_channel(self) -> Optional[discord.ForumChannel]: return guild and guild.get_channel(self.ticket_channel_id) # type: ignore +class GuildSettings(msgspec.Struct, frozen=True): + account_age: datetime.timedelta = datetime.timedelta(hours=2) + guild_age: datetime.timedelta = datetime.timedelta(days=2) + mention: str = "@here" + anon_replies: bool = False + anon_reply_without_command: bool = False + anon_snippets: bool = False + + def to_dict(self): + return {f: getattr(self, f) for f in self.__struct_fields__} + + +class PartialGuildSettings(msgspec.Struct, frozen=True): + mention: str = "@here" + anon_replies: bool = False + anon_reply_without_command: bool = False + anon_snippets: bool = False + + def to_dict(self): + return {f: getattr(self, f) for f in self.__struct_fields__} + + +class GuildWebhook(msgspec.Struct, frozen=True): + bot: Rodhaj + id: int + category_id: int + ticket_channel_id: int + logging_channel_id: int + logging_broadcast_url: str + ticket_broadcast_url: str + + @property + def category_channel(self) -> Optional[discord.CategoryChannel]: + guild = self.bot.get_guild(self.id) + return guild and guild.get_channel(self.category_id) # type: ignore + + @property + def logging_channel(self) -> Optional[discord.TextChannel]: + guild = self.bot.get_guild(self.id) + return guild and guild.get_channel(self.logging_channel_id) # type: ignore + + @property + def ticket_channel(self) -> Optional[discord.ForumChannel]: + guild = self.bot.get_guild(self.id) + return guild and guild.get_channel(self.ticket_channel_id) # type: ignore + + class GuildWebhookDispatcher: def __init__(self, bot: Rodhaj, guild_id: int): self.bot = bot @@ -150,7 +218,7 @@ async def get_ticket_webhook(self) -> Optional[discord.Webhook]: ) @alru_cache() - async def get_config(self) -> Optional[GuildConfig]: + async def get_config(self) -> Optional[GuildWebhook]: query = """ SELECT id, category_id, ticket_channel_id, logging_channel_id, logging_broadcast_url, ticket_broadcast_url FROM guild_config @@ -158,10 +226,93 @@ async def get_config(self) -> Optional[GuildConfig]: """ rows = await self.pool.fetchrow(query, self.guild_id) if rows is None: + self.get_config.cache_invalidate() return None - config = GuildConfig(bot=self.bot, **dict(rows)) - return config + return GuildWebhook(bot=self.bot, **dict(rows)) + + +class ConfigHelpEntry(msgspec.Struct, frozen=True): + key: str + default: str + description: str + examples: list[str] + notes: list[str] + + +class ConfigEntryEmbed(Embed): + def __init__(self, entry: ConfigHelpEntry, **kwargs): + super().__init__(**kwargs) + self.title = entry.key + self.description = entry.description + self.add_field(name="Default", value=entry.default, inline=False) + self.add_field(name="Example(s)", value="\n".join(entry.examples), inline=False) + self.add_field( + name="Notes", + value="\n".join(f"- {note}" for note in entry.notes) or None, + inline=False, + ) + + +class ConfigHelpPageSource(menus.ListPageSource): + async def format_page(self, menu: ConfigHelpPages, entry: ConfigHelpEntry): + embed = ConfigEntryEmbed(entry=entry) + + maximum = self.get_max_pages() + if maximum > 1: + embed.set_footer(text=f"Page {menu.current_page + 1}/{maximum}") + return embed + + +class ConfigHelpPages(RoboPages): + def __init__(self, entries: list[ConfigHelpEntry], *, ctx: GuildContext): + super().__init__(ConfigHelpPageSource(entries, per_page=1), ctx=ctx) + self.embed = discord.Embed() + + +class ConfigPageSource(menus.AsyncIteratorPageSource): + def __init__(self, entries: dict[str, Any], active: Optional[bool] = None): + super().__init__(self.config_iterator(entries), per_page=20) + self.active = active + + async def config_iterator(self, entries: dict[str, Any]) -> AsyncIterator[str]: + for key, entry in entries.items(): + result = f"**{key}:** {entry}" + # Wtf is wrong with me - Noelle + if self.active is None: + if isinstance(entry, datetime.timedelta): + entry = humanize.precisedelta(entry) + yield result + elif entry is self.active: + yield result + + async def format_page(self, menu: ConfigPages, entries: list[str]): + pages = [] + for _, entry in enumerate(entries, start=menu.current_page * self.per_page): + pages.append(f"{entry}") + + menu.embed.description = "\n".join(pages) + return menu.embed + + +class ConfigPages(RoboPages): + def __init__( + self, + entries: dict[str, Any], + *, + ctx: GuildContext, + active: Optional[bool] = None, + ): + super().__init__(ConfigPageSource(entries, active), ctx=ctx) + self.embed = discord.Embed(colour=discord.Colour.from_rgb(200, 168, 255)) + + +class ConfigOptionFlags(commands.FlagConverter): + active: Optional[bool] = commands.flag( + name="active", + default=None, + description="Whether to show current active options or not. Using None will show all options", + ) class SetupFlags(commands.FlagConverter): @@ -177,10 +328,47 @@ class SetupFlags(commands.FlagConverter): ) +class ConfigKeyConverter(commands.Converter): + def disambiguate(self, argument: str, keys: list[str]) -> str: + closest = difflib.get_close_matches(argument, keys) + if len(closest) == 0: + return "Key not found." + + close_keys = "\n".join(c for c in closest) + return f"Key not found. Did you mean...\n{close_keys}" + + async def convert(self, ctx: GuildContext, argument: str) -> str: + lowered = argument.lower() + cog: Optional[Config] = ctx.bot.get_cog("Config") # type: ignore + + if not cog: + raise RuntimeError("Unable to get Config cog") + + if lowered not in cog.config_keys: + raise commands.BadArgument(self.disambiguate(lowered, cog.config_keys)) + + return lowered + + +class ConfigValueConverter(commands.Converter): + async def convert(self, ctx: GuildContext, argument: str) -> str: + true_options = ("yes", "y", "true", "t", "1", "enable", "on") + false_options = ("no", "n", "false", "f", "0", "disable", "off") + lowered = argument.lower() + + # we need to check for whether people are silently passing boolean options or not + if lowered in true_options or lowered in false_options: + raise commands.BadArgument( + f"Please use `{ctx.prefix or 'r>'}config toggle` to enable/disable boolean configuration options instead." + ) + + return argument + + class PrefixConverter(commands.Converter): async def convert(self, ctx: GuildContext, argument: str): user_id = ctx.bot.user.id # type: ignore # Already logged in by this time - if argument.startswith((f"<@{user_id}>", f"<@!{user_id}>")): + if argument.startswith((f"<@{user_id}>", f"<@!{user_id}>", "r>")): raise commands.BadArgument("That is a reserved prefix already in use.") if len(argument) > 100: raise commands.BadArgument("That prefix is too long.") @@ -193,31 +381,101 @@ class Config(commands.Cog): def __init__(self, bot: Rodhaj) -> None: self.bot = bot self.pool = self.bot.pool + self.config_keys = [ + "account_age", + "guild_age", + "mention", + "anon_replies", + "anon_reply_without_command", + "anon_snippets", + ] + self.options_help = OptionsHelp(OPTIONS_FILE) @property def display_emoji(self) -> discord.PartialEmoji: return discord.PartialEmoji(name="\U0001f6e0") + ### Configuration utilities + @alru_cache() async def get_guild_config(self, guild_id: int) -> Optional[GuildConfig]: - # Normally using the star is bad practice but... - # Since I don't want to write out every single column to select, - # we are going to use the star - # The guild config roughly maps to it as well - query = "SELECT * FROM guild_config WHERE id = $1;" + query = """ + SELECT id, category_id, ticket_channel_id, logging_channel_id, logging_broadcast_url, ticket_broadcast_url, prefix + FROM guild_config + WHERE id = $1; + """ rows = await self.pool.fetchrow(query, guild_id) if rows is None: + self.get_guild_config.cache_invalidate(guild_id) return None config = GuildConfig(bot=self.bot, **dict(rows)) return config - def clean_prefixes(self, prefixes: Union[str, list[str]]) -> str: - if isinstance(prefixes, str): - return f"`{prefixes}`" + @alru_cache() + async def get_guild_settings(self, guild_id: int) -> Optional[GuildSettings]: + query = ( + "SELECT account_age, guild_age, settings FROM guild_config WHERE id = $1;" + ) + rows = await self.pool.fetchrow(query, guild_id) + if rows is None: + self.get_guild_settings.cache_invalidate(guild_id) + return None + return GuildSettings( + account_age=rows["account_age"], + guild_age=rows["guild_age"], + **rows["settings"], + ) - return ", ".join(f"`{prefix}`" for prefix in prefixes[2:]) + @alru_cache() + async def get_partial_guild_settings( + self, guild_id: int + ) -> Optional[PartialGuildSettings]: + query = "SELECT settings FROM guild_config WHERE id = $1;" + rows = await self.pool.fetchrow(query, guild_id) + if rows is None: + self.get_partial_guild_settings.cache_invalidate(guild_id) + return None + return PartialGuildSettings( + **rows["settings"], + ) + + async def set_guild_settings( + self, + key: str, + value: Union[str, bool], + *, + config_type: ConfigType, + ctx: GuildContext, + ): + current_guild_settings = await self.get_partial_guild_settings(ctx.guild.id) + + # If there are no guild configurations, then we have an issue here + # we will denote this with an error + if not current_guild_settings: + raise RuntimeError("Guild settings could not be found") + + # There is technically an faster method + # of directly modifying the subscripted path... + # But for the reason of autonomic guarantees, the whole entire dict should be modified + query = """ + UPDATE guild_config + SET settings = $2::jsonb + WHERE id = $1; + """ + guild_dict = current_guild_settings.to_dict() + original_value = guild_dict.get(key) + if original_value and original_value is value: + await ctx.send(f"`{key}` is already set to `{value}`!") + return + + guild_dict[key] = value + await self.bot.pool.execute(query, ctx.guild.id, guild_dict) + self.get_partial_guild_settings.cache_invalidate(ctx.guild.id) + + command_type = "Toggled" if config_type == ConfigType.TOGGLE else "Set" + await ctx.send(f"{command_type} `{key}` from `{original_value}` to `{value}`") - ### Blocklist Utilities + ### Blocklist utilities async def can_be_blocked(self, ctx: GuildContext, entity: discord.Member) -> bool: if entity.id == ctx.author.id or await self.bot.is_owner(entity) or entity.bot: @@ -242,34 +500,47 @@ async def get_block_ticket( return BlocklistTicket(cog=tickets_cog, thread=cached_ticket.thread) + ### Prefix utilities + + def clean_prefixes(self, prefixes: Union[str, list[str]]) -> str: + if isinstance(prefixes, str): + return f"`{prefixes}`" + + return ", ".join(f"`{prefix}`" for prefix in prefixes[2:]) + ### Misc Utilities async def _handle_error( - self, ctx: GuildContext, error: commands.CommandError + self, error: commands.CommandError, *, ctx: GuildContext ) -> None: if isinstance(error, commands.CommandOnCooldown): embed = CooldownEmbed(error.retry_after) await ctx.send(embed=embed) + elif isinstance(error, commands.BadArgument): + await ctx.send(str(error)) - @check_permissions(manage_guild=True) + @is_manager() @bot_check_permissions(manage_channels=True, manage_webhooks=True) @commands.guild_only() - @commands.hybrid_group(name="config") - async def config(self, ctx: GuildContext) -> None: - """Commands to configure, setup, or delete Rodhaj""" + @commands.group(name="rodhaj") + async def rodhaj(self, ctx: GuildContext) -> None: + """Commands for setup/removal of Rodhaj""" if ctx.invoked_subcommand is None: await ctx.send_help(ctx.command) + @is_manager() + @bot_check_permissions(manage_channels=True, manage_webhooks=True) @commands.cooldown(1, 20, commands.BucketType.guild) - @config.command(name="setup", usage="ticket_name: log_name: ") + @commands.guild_only() + @rodhaj.command(name="setup", usage="ticket_name: log_name: ") async def setup(self, ctx: GuildContext, *, flags: SetupFlags) -> None: """First-time setup for Rodhaj You only need to run this once """ - await ctx.defer() guild_id = ctx.guild.id dispatcher = GuildWebhookDispatcher(self.bot, guild_id) + guild_settings = GuildSettings() config = await dispatcher.get_config() if ( @@ -392,7 +663,7 @@ async def setup(self, ctx: GuildContext, *, flags: SetupFlags) -> None: return query = """ - INSERT INTO guild_config (id, category_id, ticket_channel_id, logging_channel_id, logging_broadcast_url, ticket_broadcast_url, prefix) + INSERT INTO guild_config (id, category_id, ticket_channel_id, logging_channel_id, logging_broadcast_url, ticket_broadcast_url, prefix, settings) VALUES ($1, $2, $3, $4, $5, $6, $7); """ try: @@ -405,6 +676,7 @@ async def setup(self, ctx: GuildContext, *, flags: SetupFlags) -> None: lgc_webhook.url, tc_webhook.url, [], + guild_settings.to_dict(), ) except asyncpg.UniqueViolationError: await ticket_channel.delete(reason=delete_reason) @@ -419,11 +691,13 @@ async def setup(self, ctx: GuildContext, *, flags: SetupFlags) -> None: msg = f"Rodhaj channels successfully created! The ticket channel can be found under {ticket_channel.mention}" await ctx.send(msg) + @is_manager() + @bot_check_permissions(manage_channels=True, manage_webhooks=True) @commands.cooldown(1, 20, commands.BucketType.guild) - @config.command(name="delete") + @commands.guild_only() + @rodhaj.command(name="delete") async def delete(self, ctx: GuildContext) -> None: """Permanently deletes Rodhaj channels and tickets.""" - await ctx.defer() guild_id = ctx.guild.id dispatcher = GuildWebhookDispatcher(self.bot, guild_id) @@ -475,15 +749,195 @@ async def delete(self, ctx: GuildContext) -> None: async def on_setup_error( self, ctx: GuildContext, error: commands.CommandError ) -> None: - await self._handle_error(ctx, error) + await self._handle_error(error, ctx=ctx) @delete.error async def on_delete_error( self, ctx: GuildContext, error: commands.CommandError ) -> None: - await self._handle_error(ctx, error) + await self._handle_error(error, ctx=ctx) - @check_permissions(manage_guild=True) + @is_manager() + @commands.guild_only() + @commands.hybrid_group(name="config") + async def config(self, ctx: GuildContext) -> None: + """Modifiable configuration layer for Rodhaj""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @is_manager() + @commands.guild_only() + @config.command(name="options", usage="active: ") + async def config_options( + self, ctx: GuildContext, *, flags: ConfigOptionFlags + ) -> None: + """Shows options for configuration + + If the active flag is not supplied, then all of the options will be displayed. + The active flag controls whether active settings are shown are not. For the + purposes of simplicity, non-boolean options are not considered "active". + """ + guild_settings = await self.get_guild_settings(ctx.guild.id) + if guild_settings is None: + msg = ( + "It seems like Rodhaj has not been set up\n" + f"If you want to set up Rodhaj, please run `{ctx.prefix or 'r>'}rodhaj setup`" + ) + await ctx.send(msg) + return + + pages = ConfigPages(guild_settings.to_dict(), ctx=ctx, active=flags.active) + await pages.start() + + @is_manager() + @commands.guild_only() + @config.command(name="help", aliases=["info"]) + @app_commands.describe(key="Configuration key to use for lookup") + async def config_help( + self, ctx: GuildContext, key: Annotated[str, ConfigKeyConverter] + ) -> None: + """Shows help information for different configuration options""" + # Because we are using the converter, all options are guaranteed to be correct + embed = ConfigEntryEmbed(ConfigHelpEntry(key=key, **self.options_help[key])) + await ctx.send(embed=embed) + + @is_manager() + @commands.guild_only() + @config.command(name="help-all") + async def config_help_all(self, ctx: GuildContext): + """Shows all possible help information for all configurations""" + # We need to separate this since we are using the key converter. If it is an invalid option, it passes back None, + # thus causing it to show all entries. This isn't that useful when you just made one mistake. + # Modmail handles this differently by internally looking for the key, and giving an whole embed list of possible options + converted = [ + ConfigHelpEntry(key=key, **item) + for key, item in self.options_help.all().items() + ] + pages = ConfigHelpPages(entries=converted, ctx=ctx) + await pages.start() + + @is_manager() + @commands.guild_only() + @config.command(name="set-age") + @app_commands.describe( + type="Type of minimum age to set", + duration="The duration that this minimum age should be. E.g. 2 days", + ) + async def config_set_age( + self, + ctx: GuildContext, + type: Literal["guild", "account"], + *, + duration: Annotated[ + FriendlyTimeResult, UserFriendlyTime(commands.clean_content, default="…") + ], + ) -> None: + """Sets an minimum duration for age-related options + + This command handles all age-related options. This means you can use this + to set the minimum age required to use Rodhaj + """ + if type in "guild": + clause = "SET guild_age = $2" + else: + clause = "SET account_age = $2" + query = f""" + UPDATE guild_config + {clause} + WHERE id = $1; + """ + await self.bot.pool.execute(query, ctx.guild.id, duration.td) + self.get_guild_settings.cache_invalidate(ctx.guild.id) + await ctx.send(f"Set `{type}_age` to `{duration.td}`") + + @is_manager() + @commands.guild_only() + @config.command(name="set") + @app_commands.describe( + key="Configuration key to use for lookup", + value="Value to set for configuration", + ) + async def config_set( + self, + ctx: GuildContext, + key: Annotated[str, ConfigKeyConverter], + *, + value: Annotated[str, ConfigValueConverter], + ) -> None: + """Sets an option for configuration + + If you are looking to toggle an option within the configuration, then please use + `config toggle` instead. + """ + if key in ["account_age", "guild_age"]: + await ctx.send( + "Please use `config set-age` for setting configuration values that are related with ages" + ) + return + elif key not in "mention": + await ctx.send( + "Please use `config toggle` for setting configuration values that are boolean" + ) + return + + await self.set_guild_settings(key, value, config_type=ConfigType.SET, ctx=ctx) + + @is_manager() + @commands.guild_only() + @config.command(name="toggle") + @app_commands.describe( + key="Configuration key to use for lookup", + value="Boolean option to set the configuration", + ) + async def config_toggle( + self, ctx: GuildContext, key: Annotated[str, ConfigKeyConverter], *, value: bool + ) -> None: + """Toggles an boolean option for configuration + + + If you are looking to set an option within the configuration, then please use + `config set` instead. + """ + if key in ["account_age", "guild_age"]: + await ctx.send( + f"Please use `{ctx.prefix or 'r>'}config set-age` for setting configuration values that are fixed values" + ) + return + elif key in "mention": + await ctx.send( + "Please use `config set` for setting configuration values that require a set value" + ) + return + + await self.set_guild_settings( + key, value, config_type=ConfigType.TOGGLE, ctx=ctx + ) + + @config_set_age.error + async def on_config_set_age_error( + self, ctx: GuildContext, error: commands.CommandError + ): + await self._handle_error(error, ctx=ctx) + + @config_set.error + async def on_config_set_error( + self, ctx: GuildContext, error: commands.CommandError + ): + await self._handle_error(error, ctx=ctx) + + @config_toggle.error + async def on_config_toggle_error( + self, ctx: GuildContext, error: commands.CommandError + ): + await self._handle_error(error, ctx=ctx) + + @config_help.error + async def on_config_help_error( + self, ctx: GuildContext, error: commands.CommandError + ): + await self._handle_error(error, ctx=ctx) + + @is_manager() @commands.guild_only() @config.group(name="prefix", fallback="info") async def prefix(self, ctx: GuildContext) -> None: @@ -500,6 +954,8 @@ async def prefix(self, ctx: GuildContext) -> None: embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon.url) # type: ignore await ctx.send(embed=embed) + @is_manager() + @commands.guild_only() @prefix.command(name="add") @app_commands.describe(prefix="The new prefix to add") async def prefix_add( @@ -509,7 +965,7 @@ async def prefix_add( prefixes = await get_prefix(self.bot, ctx.message) # 2 are the mention prefixes, which are always prepended on the list of prefixes - if isinstance(prefixes, list) and len(prefixes) > 12: + if isinstance(prefixes, list) and len(prefixes) > 13: await ctx.send( "You can not have more than 10 custom prefixes for your server" ) @@ -527,6 +983,8 @@ async def prefix_add( get_prefix.cache_invalidate(self.bot, ctx.message) await ctx.send(f"Added prefix: `{prefix}`") + @is_manager() + @commands.guild_only() @prefix.command(name="edit") @app_commands.describe( old="The prefix to edit", new="A new prefix to replace the old" @@ -554,6 +1012,8 @@ async def prefix_edit( else: await ctx.send("The prefix is not in the list of prefixes for your server") + @is_manager() + @commands.guild_only() @prefix.command(name="delete") @app_commands.describe(prefix="The prefix to delete") async def prefix_delete( @@ -593,6 +1053,7 @@ async def blocklist(self, ctx: GuildContext) -> None: await pages.start() @check_permissions(manage_messages=True, manage_roles=True, moderate_members=True) + @commands.guild_only() @blocklist.command(name="add") @app_commands.describe( entity="The member to add to the blocklist", @@ -653,6 +1114,7 @@ async def blocklist_add( await ctx.send(f"{entity.mention} has been blocked") @check_permissions(manage_messages=True, manage_roles=True, moderate_members=True) + @commands.guild_only() @blocklist.command(name="remove") @app_commands.describe(entity="The member to remove from the blocklist") async def blocklist_remove(self, ctx: GuildContext, entity: discord.Member) -> None: diff --git a/bot/cogs/tickets.py b/bot/cogs/tickets.py index 4438a68..99ad2f8 100644 --- a/bot/cogs/tickets.py +++ b/bot/cogs/tickets.py @@ -243,7 +243,7 @@ async def create_ticket(self, ticket: TicketThread) -> Optional[TicketOutput]: ] processed_tags = [tag for tag in applied_tags if tag is not None] - content = f"({ticket.user.display_name}, {discord.utils.format_dt(ticket.created_at)})\n\n{ticket.content}" + content = f"({ticket.mention} - {ticket.user.display_name}, {discord.utils.format_dt(ticket.created_at)})\n\n{ticket.content}" created_ticket = await tc.create_thread( applied_tags=processed_tags, name=ticket.title, diff --git a/bot/launcher.py b/bot/launcher.py index affeb02..b834829 100644 --- a/bot/launcher.py +++ b/bot/launcher.py @@ -7,7 +7,7 @@ from aiohttp import ClientSession from libs.utils import KeyboardInterruptHandler, RodhajLogger from libs.utils.config import RodhajConfig -from rodhaj import Rodhaj +from rodhaj import Rodhaj, init if os.name == "nt": from winloop import run @@ -27,7 +27,7 @@ async def main() -> None: async with ClientSession() as session, asyncpg.create_pool( - dsn=POSTGRES_URI, min_size=25, max_size=25, command_timeout=30 + dsn=POSTGRES_URI, min_size=25, max_size=25, init=init, command_timeout=30 ) as pool: async with Rodhaj( config=config, intents=intents, session=session, pool=pool diff --git a/bot/libs/tickets/structs.py b/bot/libs/tickets/structs.py index fedf054..173e640 100644 --- a/bot/libs/tickets/structs.py +++ b/bot/libs/tickets/structs.py @@ -29,6 +29,7 @@ class TicketThread(msgspec.Struct): title: str user: Union[discord.User, discord.Member] location_id: int + mention: str content: str tags: list[str] files: list[discord.File] diff --git a/bot/libs/tickets/views.py b/bot/libs/tickets/views.py index afbd7fc..34a7c00 100644 --- a/bot/libs/tickets/views.py +++ b/bot/libs/tickets/views.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from libs.utils.context import RoboContext + from bot.cogs.config import Config from bot.cogs.tickets import Tickets from bot.rodhaj import Rodhaj @@ -133,6 +134,7 @@ def __init__( bot: Rodhaj, ctx: RoboContext, cog: Tickets, + config_cog: Config, content: str, guild: discord.Guild, delete_after: bool = True, @@ -142,6 +144,7 @@ def __init__( self.bot = bot self.ctx = ctx self.cog = cog + self.config_cog = config_cog self.content = content self.guild = guild self.delete_after = delete_after @@ -162,6 +165,18 @@ async def delete_response(self, interaction: discord.Interaction): self.stop() + async def get_or_fetch_member(self, member_id: int) -> Optional[discord.Member]: + member = self.guild.get_member(member_id) + if member is not None: + return member + + members = await self.guild.query_members( + limit=1, user_ids=[member_id], cache=True + ) + if not members: + return None + return members[0] + @discord.ui.button( label="Checklist", style=discord.ButtonStyle.primary, @@ -228,6 +243,31 @@ async def confirm( applied_tags = [k for k, v in tags.items() if v is True] + guild_settings = await self.config_cog.get_guild_settings(self.guild.id) + potential_member = await self.get_or_fetch_member(author.id) + + if not guild_settings: + await interaction.response.send_message( + "Unable to find guild settings", ephemeral=True + ) + return + + if (self.guild.created_at - interaction.created_at) < guild_settings.guild_age: + await interaction.response.send_message( + "The guild is too young in order to utilize Rodhaj.", ephemeral=True + ) + return + elif ( + potential_member + ): # Since we are checking join times, if we don't have the proper member, we can only skip it. + joined_at = potential_member.joined_at or discord.utils.utcnow() + if (joined_at - interaction.created_at) < guild_settings.account_age: + await interaction.response.send_message( + "This account joined the server too soon in order to utilize Rodhaj.", + ephemeral=True, + ) + return + if not status.title.is_set() or not status.tags.is_set(): dict_status = {"title": status.title, "tags": status.tags} formatted_status = "\n".join( @@ -255,6 +295,7 @@ async def confirm( title=title, user=author, location_id=self.guild.id, + mention=guild_settings.mention, content=self.content, tags=applied_tags, files=files, diff --git a/bot/libs/utils/config.py b/bot/libs/utils/config.py index 9567b08..ec5526e 100644 --- a/bot/libs/utils/config.py +++ b/bot/libs/utils/config.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Any, Generic, Optional, TypeVar, Union, overload +import orjson import yaml _T = TypeVar("_T") @@ -44,3 +45,39 @@ def __len__(self) -> int: def all(self) -> dict[str, Union[_T, Any]]: return self._config + + +class OptionsHelp(Generic[_T]): + def __init__(self, path: Path): + self.path = path + self._config: dict[str, Union[_T, Any]] = {} + self.load_from_file() + + def load_from_file(self) -> None: + try: + with open(self.path, "r") as f: + self._config: dict[str, Union[_T, Any]] = orjson.loads(f.read()) + except FileNotFoundError: + self._config = {} + + @overload + def get(self, key: Any) -> Optional[Union[_T, Any]]: ... + + @overload + def get(self, key: Any, default: Any) -> Union[_T, Any]: ... + + def get(self, key: Any, default: Any = None) -> Optional[Union[_T, Any]]: + """Retrieves a config entry.""" + return self._config.get(str(key), default) + + def __contains__(self, item: Any) -> bool: + return str(item) in self._config + + def __getitem__(self, item: Any) -> Union[_T, Any]: + return self._config[str(item)] + + def __len__(self) -> int: + return len(self._config) + + def all(self) -> dict[str, Union[_T, Any]]: + return self._config diff --git a/bot/libs/utils/time.py b/bot/libs/utils/time.py index b3d99c5..a274e0e 100644 --- a/bot/libs/utils/time.py +++ b/bot/libs/utils/time.py @@ -1,22 +1,21 @@ from __future__ import annotations import datetime -from typing import Optional, Sequence +import re +from typing import TYPE_CHECKING, Any, Optional, Sequence, Union +import parsedatetime as pdt from dateutil.relativedelta import relativedelta +from discord.ext import commands +# Monkey patch mins and secs into the units +units = pdt.pdtLocales["en_US"].units +units["minutes"].append("mins") +units["seconds"].append("secs") -class Plural: - def __init__(self, value: int): - self.value: int = value - - def __format__(self, format_spec: str) -> str: - v = self.value - singular, _, plural = format_spec.partition("|") - plural = plural or f"{singular}s" - if abs(v) != 1: - return f"{v} {plural}" - return f"{v} {singular}" +if TYPE_CHECKING: + from libs.utils.context import RoboContext + from typing_extensions import Self def human_join(seq: Sequence[str], delim: str = ", ", final: str = "or") -> str: @@ -33,8 +32,6 @@ def human_join(seq: Sequence[str], delim: str = ", ", final: str = "or") -> str: return delim.join(seq[:-1]) + f" {final} {seq[-1]}" -# The old system does work, but as noted, can be inaccurate -# This system is from RDanny and should provide more accurate results def human_timedelta( dt: datetime.datetime, *, @@ -108,3 +105,289 @@ def human_timedelta( return human_join(output, final="and") + output_suffix else: return " ".join(output) + output_suffix + + +class Plural: + def __init__(self, value: int): + self.value: int = value + + def __format__(self, format_spec: str) -> str: + v = self.value + singular, _, plural = format_spec.partition("|") + plural = plural or f"{singular}s" + if abs(v) != 1: + return f"{v} {plural}" + return f"{v} {singular}" + + +class ShortTime: + compiled = re.compile( + """ + (?:(?P[0-9])(?:years?|y))? # e.g. 2y + (?:(?P[0-9]{1,2})(?:months?|mon?))? # e.g. 2months + (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w + (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d + (?:(?P[0-9]{1,5})(?:hours?|hr?s?))? # e.g. 12h + (?:(?P[0-9]{1,5})(?:minutes?|m(?:ins?)?))? # e.g. 10m + (?:(?P[0-9]{1,5})(?:seconds?|s(?:ecs?)?))? # e.g. 15s + """, + re.VERBOSE, + ) + + discord_fmt = re.compile(r"[0-9]+)(?:\:?[RFfDdTt])?>") + + dt: datetime.datetime + + def __init__( + self, + argument: str, + *, + now: Optional[datetime.datetime] = None, + tzinfo: datetime.tzinfo = datetime.timezone.utc, + ): + match = self.compiled.fullmatch(argument) + if match is None or not match.group(0): + match = self.discord_fmt.fullmatch(argument) + if match is not None: + self.dt = datetime.datetime.fromtimestamp( + int(match.group("ts")), tz=datetime.timezone.utc + ) + if tzinfo is not datetime.timezone.utc: + self.dt = self.dt.astimezone(tzinfo) + return + else: + raise commands.BadArgument("invalid time provided") + + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + now = now or datetime.datetime.now(datetime.timezone.utc) + self.dt = now + relativedelta(**data) # type: ignore + if tzinfo is not datetime.timezone.utc: + self.dt = self.dt.astimezone(tzinfo) + + @classmethod + async def convert(cls, ctx: RoboContext, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at, tzinfo=datetime.timezone.utc) + + +class HumanTime: + calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) + + def __init__( + self, + argument: str, + *, + now: Optional[datetime.datetime] = None, + tzinfo: datetime.tzinfo = datetime.timezone.utc, + ): + now = now or datetime.datetime.now(tzinfo) + dt, status = self.calendar.parseDT(argument, sourceTime=now, tzinfo=None) + if not status.hasDateOrTime: # type: ignore (not much I could do here...) + raise commands.BadArgument( + 'invalid time provided, try e.g. "tomorrow" or "3 days"' + ) + + if not status.hasTime: # type: ignore + # replace it with the current time + dt = dt.replace( + hour=now.hour, + minute=now.minute, + second=now.second, + microsecond=now.microsecond, + ) + + self.dt: datetime.datetime = dt.replace(tzinfo=tzinfo) + if now.tzinfo is None: + now = now.replace(tzinfo=datetime.timezone.utc) + self._past: bool = self.dt < now + + @classmethod + async def convert(cls, ctx: RoboContext, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at, tzinfo=datetime.timezone.utc) + + +class Time(HumanTime): + def __init__( + self, + argument: str, + *, + now: Optional[datetime.datetime] = None, + tzinfo: datetime.tzinfo = datetime.timezone.utc, + ): + try: + o = ShortTime(argument, now=now, tzinfo=tzinfo) + except Exception: + super().__init__(argument, now=now, tzinfo=tzinfo) + else: + self.dt = o.dt + self._past = False + + +class FutureTime(Time): + def __init__( + self, + argument: str, + *, + now: Optional[datetime.datetime] = None, + tzinfo: datetime.tzinfo = datetime.timezone.utc, + ): + super().__init__(argument, now=now, tzinfo=tzinfo) + + if self._past: + raise commands.BadArgument("this time is in the past") + + +class FriendlyTimeResult: + dt: datetime.datetime + td: datetime.timedelta + arg: str + + __slots__ = ("dt", "now", "td", "arg") + + def __init__(self, dt: datetime.datetime, now: datetime.datetime): + self.dt = dt + self.arg = "" + + self.td = dt.replace(microsecond=0) - now.replace(microsecond=0) + + async def ensure_constraints( + self, + ctx: RoboContext, + uft: UserFriendlyTime, + now: datetime.datetime, + remaining: str, + ) -> None: + if self.dt < now: + raise commands.BadArgument("This time is in the past.") + + if not remaining: + if uft.default is None: + raise commands.BadArgument("Missing argument after the time.") + remaining = uft.default + + if uft.converter is not None: + self.arg = await uft.converter.convert(ctx, remaining) + else: + self.arg = remaining + + +class UserFriendlyTime(commands.Converter): + """That way quotes aren't absolutely necessary.""" + + def __init__( + self, + converter: Optional[Union[type[commands.Converter], commands.Converter]] = None, + *, + default: Any = None, + ): + if isinstance(converter, type) and issubclass(converter, commands.Converter): + converter = converter() + + if converter is not None and not isinstance(converter, commands.Converter): + raise TypeError("commands.Converter subclass necessary.") + + self.converter: commands.Converter = converter # type: ignore # It doesn't understand this narrowing + self.default: Any = default + + async def convert(self, ctx: RoboContext, argument: str) -> FriendlyTimeResult: + calendar = HumanTime.calendar + regex = ShortTime.compiled + now = ctx.message.created_at + + tzinfo = datetime.timezone.utc + + match = regex.match(argument) + if match is not None and match.group(0): + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + remaining = argument[match.end() :].strip() + dt = now + relativedelta(**data) # type: ignore + result = FriendlyTimeResult(dt.astimezone(tzinfo), now) + await result.ensure_constraints(ctx, self, now, remaining) + return result + + if match is None or not match.group(0): + match = ShortTime.discord_fmt.match(argument) + if match is not None: + result = FriendlyTimeResult( + datetime.datetime.fromtimestamp( + int(match.group("ts")), tz=datetime.timezone.utc + ).astimezone(tzinfo), + now, + ) + remaining = argument[match.end() :].strip() + await result.ensure_constraints(ctx, self, now, remaining) + return result + + # apparently nlp does not like "from now" + # it likes "from x" in other cases though so let me handle the 'now' case + if argument.endswith("from now"): + argument = argument[:-8].strip() + + # Have to adjust the timezone so pdt knows how to handle things like "tomorrow at 6pm" in an aware way + now = now.astimezone(tzinfo) + elements = calendar.nlp(argument, sourceTime=now) + if elements is None or len(elements) == 0: + raise commands.BadArgument( + 'Invalid time provided, try e.g. "tomorrow" or "3 days".' + ) + + # handle the following cases: + # "date time" foo + # date time foo + # foo date time + + # first the first two cases: + dt, status, begin, end, _ = elements[0] + + if not status.hasDateOrTime: + raise commands.BadArgument( + 'Invalid time provided, try e.g. "tomorrow" or "3 days".' + ) + + if begin not in (0, 1) and end != len(argument): + raise commands.BadArgument( + "Time is either in an inappropriate location, which " + "must be either at the end or beginning of your input, " + "or I just flat out did not understand what you meant. Sorry." + ) + + dt = dt.replace(tzinfo=tzinfo) + if not status.hasTime: + # replace it with the current time + dt = dt.replace( + hour=now.hour, + minute=now.minute, + second=now.second, + microsecond=now.microsecond, + ) + + if status.hasTime and not status.hasDate and dt < now: + # if it's in the past, and it has a time but no date, + # assume it's for the next occurrence of that time + dt = dt + datetime.timedelta(days=1) + + # if midnight is provided, just default to next day + if status.accuracy == pdt.pdtContext.ACU_HALFDAY: + dt = dt + datetime.timedelta(days=1) + + result = FriendlyTimeResult(dt, now) + remaining = "" + + if begin in (0, 1): + if begin == 1: + # check if it's quoted: + if argument[0] != '"': + raise commands.BadArgument("Expected quote before time input...") + + if not (end < len(argument) and argument[end] == '"'): + raise commands.BadArgument( + "If the time is quoted, you must unquote it." + ) + + remaining = argument[end + 1 :].lstrip(" ,.!") + else: + remaining = argument[end:].lstrip(" ,.!") + elif len(argument) == end: + remaining = argument[:begin].strip() + + await result.ensure_constraints(ctx, self, now, remaining) + return result diff --git a/bot/locale/options.json b/bot/locale/options.json new file mode 100644 index 0000000..d750e00 --- /dev/null +++ b/bot/locale/options.json @@ -0,0 +1,50 @@ +{ + "guild_age": { + "default": "2 days", + "description": "Sets the default age required for an guild in order to use Rodhaj", + "examples": [ + "`config set-age guild 2 days`" + ], + "notes": [] + }, + "account_age": { + "default": "2 hours", + "description": "Sets the default age required for an account that has joined the guild that Rodhaj is operating on", + "examples": [ + "`config set-age account 2 days`" + ], + "notes": [] + }, + "mention": { + "default": "@here", + "description": "Sets the default mention to use when new tickets are created", + "examples": [ + "`config set mention @here`" + ], + "notes": ["Pass in an empty string in order to disable this"] + }, + "anon_replies": { + "default": false, + "description": "Enables the anonymous replying feature of Rodhaj. This affects the guild itself.", + "examples": [ + "`config toggle anon_replies false`" + ], + "notes": [] + }, + "anon_reply_without_command": { + "default": false, + "description": "Anonymously reply without using the command", + "examples": [ + "`config toggle anon_reply_without_command true`" + ], + "notes": [] + }, + "anon_snippets": { + "default": false, + "description": "Enable the usage of sending snippets anonymously", + "examples": [ + "`config toggle anon_snippets true`" + ], + "notes": [] + } +} \ No newline at end of file diff --git a/bot/migrations/V6__guild_settings.sql b/bot/migrations/V6__guild_settings.sql new file mode 100644 index 0000000..b445390 --- /dev/null +++ b/bot/migrations/V6__guild_settings.sql @@ -0,0 +1,14 @@ +-- Revision Version: V6 +-- Revises: V5 +-- Creation Date: 2024-05-21 04:12:28.443725 UTC +-- Reason: guild_settings + +-- We can't store intervals properly in JSON format without doing some janky stuff +-- So these needs to be separate columns +ALTER TABLE IF EXISTS guild_config ADD COLUMN account_age INTERVAL DEFAULT ('2 hours'::interval) NOT NULL; +ALTER TABLE IF EXISTS guild_config ADD COLUMN guild_age INTERVAL DEFAULT ('2 days':: interval) NOT NULL; + +-- The guild settings is just an jsonb column that stores extra settings for the guild. +-- Misc settings like enabling an certain feature, etc. +-- Of course, the settings are highly structured +ALTER TABLE IF EXISTS guild_config ADD COLUMN settings JSONB DEFAULT ('{}'::jsonb) NOT NULL; \ No newline at end of file diff --git a/bot/rodhaj.py b/bot/rodhaj.py index dec574a..dc02449 100644 --- a/bot/rodhaj.py +++ b/bot/rodhaj.py @@ -6,6 +6,7 @@ import asyncpg import discord +import orjson from aiohttp import ClientSession from cogs import EXTENSIONS, VERSION from cogs.config import Blocklist, GuildWebhookDispatcher @@ -20,10 +21,28 @@ from libs.utils.reloader import Reloader if TYPE_CHECKING: + from cogs.config import Config from cogs.tickets import Tickets from libs.utils.context import RoboContext +async def init(conn: asyncpg.Connection): + # Refer to https://github.com/MagicStack/asyncpg/issues/140#issuecomment-301477123 + def _encode_jsonb(value): + return b"\x01" + orjson.dumps(value) + + def _decode_jsonb(value): + return orjson.loads(value[1:].decode("utf-8")) + + await conn.set_type_codec( + "jsonb", + schema="pg_catalog", + encoder=_encode_jsonb, + decoder=_decode_jsonb, + format="binary", + ) + + class Rodhaj(commands.Bot): """Main bot for Rodhaj""" @@ -169,6 +188,7 @@ async def on_message(self, message: discord.Message) -> None: return tickets_cog: Tickets = self.get_cog("Tickets") # type: ignore + config_cog: Config = self.get_cog("Config") # type: ignore default_tags = ReservedTags( question=False, serious=False, private=False ) @@ -193,7 +213,13 @@ async def on_message(self, message: discord.Message) -> None: ) view = TicketConfirmView( - message.attachments, self, ctx, tickets_cog, message.content, guild + message.attachments, + self, + ctx, + tickets_cog, + config_cog, + message.content, + guild, ) view.message = await author.send(embed=embed, view=view) return diff --git a/docs/user-guide/features.rst b/docs/user-guide/features.rst index 1c11a84..0d50c88 100644 --- a/docs/user-guide/features.rst +++ b/docs/user-guide/features.rst @@ -66,3 +66,20 @@ production environments. Disabling this extension will have no effect on the bot itself. + +Minimum Guild and Account Ages +------------------------------ + +By default, only guilds (aka discord servers) that are older than 2 hours, +and accounts that joined the server 2 days ago can use Rodhaj. This provides +a check to prevent spam and abuse, and can be customized within the configuration +settings. In order to override the default configurations, you can use ``?config set-age`` +in order to update these settings + +In-depth Configuration System +----------------------------- + +Rodhaj offers a in-depth configuration system, which is handled and stored +in the database. You can toggle various settings, including whether to enable +anonymous replies, set default mentions for posts, and others. More will be included +in the future. diff --git a/poetry.lock b/poetry.lock index 20fc84c..5daa967 100644 --- a/poetry.lock +++ b/poetry.lock @@ -137,13 +137,13 @@ files = [ [[package]] name = "anyio" -version = "4.3.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, - {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -413,13 +413,13 @@ files = [ [[package]] name = "cachetools" -version = "5.3.3" +version = "5.4.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ - {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, - {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, + {file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"}, + {file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"}, ] [[package]] @@ -462,13 +462,13 @@ files = [ [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -747,13 +747,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.1" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, - {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -889,15 +889,29 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "humanize" +version = "4.10.0" +description = "Python humanize utilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "humanize-4.10.0-py3-none-any.whl", hash = "sha256:39e7ccb96923e732b5c2e27aeaa3b10a8dfeeba3eb965ba7b74a3eb0e30040a6"}, + {file = "humanize-4.10.0.tar.gz", hash = "sha256:06b6eb0293e4b85e8d385397c5868926820db32b9b654b932f57fa41c23c9978"}, +] + +[package.extras] +tests = ["freezegun", "pytest", "pytest-cov"] + [[package]] name = "identify" -version = "2.5.36" +version = "2.6.0" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, - {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, + {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, + {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, ] [package.extras] @@ -927,13 +941,13 @@ files = [ [[package]] name = "import-expression" -version = "1.1.4" +version = "1.1.5" description = "Parses a superset of Python allowing for inline module import expressions" optional = false python-versions = "*" files = [ - {file = "import_expression-1.1.4-py3-none-any.whl", hash = "sha256:292099910a4dcc65ba562377cd2265487ba573dd63d73bdee5deec36ca49555b"}, - {file = "import_expression-1.1.4.tar.gz", hash = "sha256:06086a6ab3bfa528b1c478e633d6adf2b3a990e31440f6401b0f3ea12b0659a9"}, + {file = "import_expression-1.1.5-py3-none-any.whl", hash = "sha256:f60c3765dbf2f41928b9c6ef79d632209b6705fc8f30e281ed1a492ed026b10f"}, + {file = "import_expression-1.1.5.tar.gz", hash = "sha256:9959588fcfc8dcb144a0725176cfef6c28c7db1fc2d683625025e687516d40c1"}, ] [package.dependencies] @@ -944,22 +958,22 @@ test = ["pytest", "pytest-cov"] [[package]] name = "importlib-metadata" -version = "7.1.0" +version = "8.0.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"}, - {file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"}, + {file = "importlib_metadata-8.0.0-py3-none-any.whl", hash = "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f"}, + {file = "importlib_metadata-8.0.0.tar.gz", hash = "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "jinja2" @@ -1227,71 +1241,73 @@ files = [ [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "orjson" -version = "3.10.3" +version = "3.10.6" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.8" files = [ - {file = "orjson-3.10.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9fb6c3f9f5490a3eb4ddd46fc1b6eadb0d6fc16fb3f07320149c3286a1409dd8"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:252124b198662eee80428f1af8c63f7ff077c88723fe206a25df8dc57a57b1fa"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f3e87733823089a338ef9bbf363ef4de45e5c599a9bf50a7a9b82e86d0228da"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8334c0d87103bb9fbbe59b78129f1f40d1d1e8355bbed2ca71853af15fa4ed3"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1952c03439e4dce23482ac846e7961f9d4ec62086eb98ae76d97bd41d72644d7"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0403ed9c706dcd2809f1600ed18f4aae50be263bd7112e54b50e2c2bc3ebd6d"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:382e52aa4270a037d41f325e7d1dfa395b7de0c367800b6f337d8157367bf3a7"}, - {file = "orjson-3.10.3-cp310-none-win32.whl", hash = "sha256:be2aab54313752c04f2cbaab4515291ef5af8c2256ce22abc007f89f42f49109"}, - {file = "orjson-3.10.3-cp310-none-win_amd64.whl", hash = "sha256:416b195f78ae461601893f482287cee1e3059ec49b4f99479aedf22a20b1098b"}, - {file = "orjson-3.10.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:73100d9abbbe730331f2242c1fc0bcb46a3ea3b4ae3348847e5a141265479700"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a12eee96e3ab828dbfcb4d5a0023aa971b27143a1d35dc214c176fdfb29b3"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520de5e2ef0b4ae546bea25129d6c7c74edb43fc6cf5213f511a927f2b28148b"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccaa0a401fc02e8828a5bedfd80f8cd389d24f65e5ca3954d72c6582495b4bcf"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7bc9e8bc11bac40f905640acd41cbeaa87209e7e1f57ade386da658092dc16"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3582b34b70543a1ed6944aca75e219e1192661a63da4d039d088a09c67543b08"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c23dfa91481de880890d17aa7b91d586a4746a4c2aa9a145bebdbaf233768d5"}, - {file = "orjson-3.10.3-cp311-none-win32.whl", hash = "sha256:1770e2a0eae728b050705206d84eda8b074b65ee835e7f85c919f5705b006c9b"}, - {file = "orjson-3.10.3-cp311-none-win_amd64.whl", hash = "sha256:93433b3c1f852660eb5abdc1f4dd0ced2be031ba30900433223b28ee0140cde5"}, - {file = "orjson-3.10.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a39aa73e53bec8d410875683bfa3a8edf61e5a1c7bb4014f65f81d36467ea098"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0943a96b3fa09bee1afdfccc2cb236c9c64715afa375b2af296c73d91c23eab2"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e852baafceff8da3c9defae29414cc8513a1586ad93e45f27b89a639c68e8176"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18566beb5acd76f3769c1d1a7ec06cdb81edc4d55d2765fb677e3eaa10fa99e0"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd2218d5a3aa43060efe649ec564ebedec8ce6ae0a43654b81376216d5ebd42"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf20465e74c6e17a104ecf01bf8cd3b7b252565b4ccee4548f18b012ff2f8069"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7f67aa7f983c4345eeda16054a4677289011a478ca947cd69c0a86ea45e534"}, - {file = "orjson-3.10.3-cp312-none-win32.whl", hash = "sha256:17e0713fc159abc261eea0f4feda611d32eabc35708b74bef6ad44f6c78d5ea0"}, - {file = "orjson-3.10.3-cp312-none-win_amd64.whl", hash = "sha256:4c895383b1ec42b017dd2c75ae8a5b862fc489006afde06f14afbdd0309b2af0"}, - {file = "orjson-3.10.3-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:be2719e5041e9fb76c8c2c06b9600fe8e8584e6980061ff88dcbc2691a16d20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0175a5798bdc878956099f5c54b9837cb62cfbf5d0b86ba6d77e43861bcec2"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978be58a68ade24f1af7758626806e13cff7748a677faf95fbb298359aa1e20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16bda83b5c61586f6f788333d3cf3ed19015e3b9019188c56983b5a299210eb5"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ad1f26bea425041e0a1adad34630c4825a9e3adec49079b1fb6ac8d36f8b754"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9e253498bee561fe85d6325ba55ff2ff08fb5e7184cd6a4d7754133bd19c9195"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0a62f9968bab8a676a164263e485f30a0b748255ee2f4ae49a0224be95f4532b"}, - {file = "orjson-3.10.3-cp38-none-win32.whl", hash = "sha256:8d0b84403d287d4bfa9bf7d1dc298d5c1c5d9f444f3737929a66f2fe4fb8f134"}, - {file = "orjson-3.10.3-cp38-none-win_amd64.whl", hash = "sha256:8bc7a4df90da5d535e18157220d7915780d07198b54f4de0110eca6b6c11e290"}, - {file = "orjson-3.10.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9059d15c30e675a58fdcd6f95465c1522b8426e092de9fff20edebfdc15e1cb0"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d40c7f7938c9c2b934b297412c067936d0b54e4b8ab916fd1a9eb8f54c02294"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a654ec1de8fdaae1d80d55cee65893cb06494e124681ab335218be6a0691e7"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:831c6ef73f9aa53c5f40ae8f949ff7681b38eaddb6904aab89dca4d85099cb78"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99b880d7e34542db89f48d14ddecbd26f06838b12427d5a25d71baceb5ba119d"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e5e176c994ce4bd434d7aafb9ecc893c15f347d3d2bbd8e7ce0b63071c52e25"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b69a58a37dab856491bf2d3bbf259775fdce262b727f96aafbda359cb1d114d8"}, - {file = "orjson-3.10.3-cp39-none-win32.whl", hash = "sha256:b8d4d1a6868cde356f1402c8faeb50d62cee765a1f7ffcfd6de732ab0581e063"}, - {file = "orjson-3.10.3-cp39-none-win_amd64.whl", hash = "sha256:5102f50c5fc46d94f2033fe00d392588564378260d64377aec702f21a7a22912"}, - {file = "orjson-3.10.3.tar.gz", hash = "sha256:2b166507acae7ba2f7c315dcf185a9111ad5e992ac81f2d507aac39193c2c818"}, + {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, + {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, + {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, + {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, + {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, + {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, + {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, + {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, + {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, + {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, + {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, + {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, + {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, + {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, + {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, + {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, ] [[package]] @@ -1305,6 +1321,17 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "parsedatetime" +version = "2.6" +description = "Parse human-readable date/time text." +optional = false +python-versions = "*" +files = [ + {file = "parsedatetime-2.6-py3-none-any.whl", hash = "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b"}, + {file = "parsedatetime-2.6.tar.gz", hash = "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455"}, +] + [[package]] name = "platformdirs" version = "4.2.2" @@ -1574,13 +1601,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytes [[package]] name = "pyright" -version = "1.1.361" +version = "1.1.372" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.361-py3-none-any.whl", hash = "sha256:c50fc94ce92b5c958cfccbbe34142e7411d474da43d6c14a958667e35b9df7ea"}, - {file = "pyright-1.1.361.tar.gz", hash = "sha256:1d67933315666b05d230c85ea8fb97aaa2056e4092a13df87b7765bb9e8f1a8d"}, + {file = "pyright-1.1.372-py3-none-any.whl", hash = "sha256:25b15fb8967740f0949fd35b963777187f0a0404c0bd753cc966ec139f3eaa0b"}, + {file = "pyright-1.1.372.tar.gz", hash = "sha256:a9f5e0daa955daaa17e3d1ef76d3623e75f8afd5e37b437d3ff84d5b38c15420"}, ] [package.dependencies] @@ -1666,13 +1693,13 @@ files = [ [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -1712,22 +1739,6 @@ files = [ {file = "ruff-0.5.4.tar.gz", hash = "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed"}, ] -[[package]] -name = "setuptools" -version = "69.5.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -1899,13 +1910,13 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.5" +version = "2.0.6" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, - {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, + {file = "sphinxcontrib_htmlhelp-2.0.6-py3-none-any.whl", hash = "sha256:1b9af5a2671a61410a868fce050cab7ca393c218e6205cbc7f590136f207395c"}, + {file = "sphinxcontrib_htmlhelp-2.0.6.tar.gz", hash = "sha256:c6597da06185f0e3b4dc952777a04200611ef563882e0c244d27a15ee22afa73"}, ] [package.extras] @@ -1929,19 +1940,19 @@ test = ["flake8", "mypy", "pytest"] [[package]] name = "sphinxcontrib-qthelp" -version = "1.0.7" +version = "1.0.8" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, - {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, + {file = "sphinxcontrib_qthelp-1.0.8-py3-none-any.whl", hash = "sha256:323d6acc4189af76dfe94edd2a27d458902319b60fcca2aeef3b2180c106a75f"}, + {file = "sphinxcontrib_qthelp-1.0.8.tar.gz", hash = "sha256:db3f8fa10789c7a8e76d173c23364bdf0ebcd9449969a9e6a3dd31b8b7469f03"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] standalone = ["Sphinx (>=5)"] -test = ["pytest"] +test = ["defusedxml (>=0.7.1)", "pytest"] [[package]] name = "sphinxcontrib-serializinghtml" @@ -1961,13 +1972,13 @@ test = ["pytest"] [[package]] name = "starlette" -version = "0.37.2" +version = "0.38.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" files = [ - {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, - {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, + {file = "starlette-0.38.0-py3-none-any.whl", hash = "sha256:dd58f5854ca4fc476710e48d61b29fa4ff3639d42604a786f9d2091e64b95c7e"}, + {file = "starlette-0.38.0.tar.gz", hash = "sha256:1ac2291e946a56bb5ca929dbb2332fc0dfd1e609c7e4d4f2056925cc0442874e"}, ] [package.dependencies] @@ -2028,13 +2039,13 @@ files = [ [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.extras] @@ -2045,13 +2056,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.29.0" +version = "0.30.3" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, - {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, + {file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"}, + {file = "uvicorn-0.30.3.tar.gz", hash = "sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81"}, ] [package.dependencies] @@ -2511,20 +2522,20 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.18.1" +version = "3.19.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, - {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, + {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"}, + {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "8fe07bdca4be6963dd56c51e7494d42be25946eb5c8eb9010b7dae24a0dd7a7a" +content-hash = "1c8eed45c5e39bc9a938885aaac54ae6e58b23b79248c091cfe1b6c0c57982ae" diff --git a/pyproject.toml b/pyproject.toml index aa6ff3f..e674cae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ watchfiles = "^0.22.0" typing-extensions = "^4.12.2" prometheus-client = "^0.20.0" prometheus-async = "^22.2.0" +parsedatetime = "^2.6" +humanize = "^4.10.0" [tool.poetry.group.dev.dependencies] # These are pinned by major version diff --git a/requirements.txt b/requirements.txt index 9ea4329..853627d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,5 @@ msgspec>=0.18.6,<1 jishaku>=2.5.2,<3 watchfiles>=0.21.0,<1 PyYAML>=6.0.1,<7 +parsedatetime>=2.6,<3 +humanize>=4.10.0,<5