diff --git a/src/gw2/cogs/characters.py b/src/gw2/cogs/characters.py index 2f9eac8..581f806 100644 --- a/src/gw2/cogs/characters.py +++ b/src/gw2/cogs/characters.py @@ -28,32 +28,32 @@ async def characters(ctx): gw2 characters """ - await ctx.message.channel.typing() - gw2_key_dal = Gw2KeyDal(ctx.bot.db_session, ctx.bot.log) - rs = await gw2_key_dal.get_api_key_by_user(ctx.message.author.id) - if not rs: - msg = gw2_messages.NO_API_KEY - msg += gw2_messages.key_add_info_help(ctx.prefix) - msg += gw2_messages.key_more_info_help(ctx.prefix) - return await bot_utils.send_error_msg(ctx, msg) - - api_key = str(rs[0]["key"]) - gw2_api = Gw2Client(ctx.bot) - is_valid_key = await gw2_api.check_api_key(api_key) - if not isinstance(is_valid_key, dict): - msg = f"{is_valid_key.message}\n" - msg += gw2_messages.INVALID_API_KEY_HELP_MESSAGE - msg += gw2_messages.key_add_info_help(ctx.prefix) - msg += gw2_messages.key_more_info_help(ctx.prefix) - return await bot_utils.send_error_msg(ctx, msg) - - permissions = str(rs[0]["permissions"]) - if "characters" not in permissions or "account" not in permissions: - return await bot_utils.send_error_msg(ctx, gw2_messages.API_KEY_NO_PERMISSION, True) - - progress_msg = await gw2_utils.send_progress_embed( - ctx, "Please wait, I'm fetching your character data... (this may take a moment)" - ) + async with ctx.typing(): + gw2_key_dal = Gw2KeyDal(ctx.bot.db_session, ctx.bot.log) + rs = await gw2_key_dal.get_api_key_by_user(ctx.message.author.id) + if not rs: + msg = gw2_messages.NO_API_KEY + msg += gw2_messages.key_add_info_help(ctx.prefix) + msg += gw2_messages.key_more_info_help(ctx.prefix) + return await bot_utils.send_error_msg(ctx, msg) + + api_key = str(rs[0]["key"]) + gw2_api = Gw2Client(ctx.bot) + is_valid_key = await gw2_api.check_api_key(api_key) + if not isinstance(is_valid_key, dict): + msg = f"{is_valid_key.message}\n" + msg += gw2_messages.INVALID_API_KEY_HELP_MESSAGE + msg += gw2_messages.key_add_info_help(ctx.prefix) + msg += gw2_messages.key_more_info_help(ctx.prefix) + return await bot_utils.send_error_msg(ctx, msg) + + permissions = str(rs[0]["permissions"]) + if "characters" not in permissions or "account" not in permissions: + return await bot_utils.send_error_msg(ctx, gw2_messages.API_KEY_NO_PERMISSION, True) + + progress_msg = await gw2_utils.send_progress_embed( + ctx, "Please wait, I'm fetching your character data... (this may take a moment)" + ) try: # Fetch account info and all characters in parallel diff --git a/src/gw2/cogs/wvw.py b/src/gw2/cogs/wvw.py index a77bcb2..5f81254 100644 --- a/src/gw2/cogs/wvw.py +++ b/src/gw2/cogs/wvw.py @@ -18,276 +18,281 @@ class GW2WvW(GuildWars2): def __init__(self, bot): super().__init__(bot) - @GuildWars2.gw2.group() - async def wvw(self, ctx): - """Guild Wars 2 World vs World commands. - Available subcommands: - gw2 wvw info [world] - Info about a WvW world - gw2 wvw match [world] - WvW match scores - gw2 wvw kdr [world] - WvW kill/death ratios - """ +@GW2WvW.gw2.group() +async def wvw(ctx): + """Guild Wars 2 World vs World commands. - await bot_utils.invoke_subcommand(ctx, "gw2 wvw") + Available subcommands: + gw2 wvw info [world] - Info about a WvW world + gw2 wvw match [world] - WvW match scores + gw2 wvw kdr [world] - WvW kill/death ratios + """ - async def _resolve_wvw_world_id(self, ctx, gw2_api, world, error_msg): - """Resolve a WvW world/team ID from world name or account data. + await bot_utils.invoke_subcommand(ctx, "gw2 wvw") - Returns the world/team ID, or None if resolution failed (error already sent). - """ - if world: - return await gw2_utils.get_world_id(self.bot, world) - - try: - gw2_key_dal = Gw2KeyDal(self.bot.db_session, self.bot.log) - rs = await gw2_key_dal.get_api_key_by_user(ctx.message.author.id) - if not rs: - await bot_utils.send_error_msg(ctx, error_msg) - return None - - api_key = rs[0]["key"] - results = await gw2_api.call_api("account", api_key) - # Prefer WR team_id over legacy world - return results.get("wvw", {}).get("team_id") or results["world"] - except APIKeyError: - await bot_utils.send_error_msg(ctx, error_msg) - return None - except Exception as e: - await bot_utils.send_error_msg(ctx, e) - self.bot.log.error(ctx, e) - return None - - @wvw.command(name="info") - @commands.cooldown(1, GW2CoolDowns.Wvw.seconds, commands.BucketType.user) - async def info(self, ctx, *, world: str = None): - """Display WvW information for a world. Defaults to your account's world. - - Usage: - gw2 wvw info - gw2 wvw info Blackgate - """ - await ctx.message.channel.typing() - gw2_api = Gw2Client(self.bot) - - no_api_key_msg = gw2_messages.NO_API_KEY - no_api_key_msg += gw2_messages.key_add_info_help(ctx.prefix) - no_api_key_msg += gw2_messages.key_more_info_help(ctx.prefix) - - wid = await self._resolve_wvw_world_id(ctx, gw2_api, world, no_api_key_msg) - if not wid: - if world: - return await bot_utils.send_error_msg(ctx, f"{gw2_messages.INVALID_WORLD_NAME}\n{world}") - return None - progress_msg = await gw2_utils.send_progress_embed( - ctx, "Please wait, I'm fetching WvW info... (this may take a moment)" - ) +@wvw.command(name="info") +@commands.cooldown(1, GW2CoolDowns.Wvw.seconds, commands.BucketType.user) +async def info(ctx, *, world: str = None): + """Display WvW information for a world. Defaults to your account's world. - try: - # Fetch match data and world info in parallel when possible - if is_wr_team_id(wid): - matches = await gw2_api.call_api(f"wvw/matches?world={wid}") - world_name = get_team_name(wid) or f"Team {wid}" - population = "N/A" - else: - matches, worldinfo = await asyncio.gather( - gw2_api.call_api(f"wvw/matches?world={wid}"), - gw2_api.call_api(f"worlds?id={wid}"), - ) - world_name = worldinfo["name"] - population = worldinfo["population"] - except Exception as e: - await progress_msg.delete() - await bot_utils.send_error_msg(ctx, e) - return ctx.bot.log.error(ctx, e) + Usage: + gw2 wvw info + gw2 wvw info Blackgate + """ + await ctx.message.channel.typing() + gw2_api = Gw2Client(ctx.bot) - tier = _resolve_tier(matches) + no_api_key_msg = gw2_messages.NO_API_KEY + no_api_key_msg += gw2_messages.key_add_info_help(ctx.prefix) + no_api_key_msg += gw2_messages.key_more_info_help(ctx.prefix) - worldcolor = None - for key, value in matches["all_worlds"].items(): - if wid in value: - worldcolor = key - if not worldcolor: - return await bot_utils.send_error_msg(ctx, gw2_messages.WORLD_COLOR_ERROR) - - match worldcolor: - case "red": - color = discord.Color.red() - case "green": - color = discord.Color.green() - case "blue": - color = discord.Color.blue() - case _: - color = discord.Color.default() - - ppt = 0 - score = format(matches["scores"][worldcolor], ",d") - victoryp = matches["victory_points"][worldcolor] - - await ctx.message.channel.typing() - for m in matches["maps"]: - for objective in m["objectives"]: - if objective["owner"].lower() == worldcolor: - ppt += objective["points_tick"] - - if population == "VeryHigh": - population = "Very high" - - kills = matches["kills"][worldcolor] - deaths = matches["deaths"][worldcolor] - - if kills == 0 or deaths == 0: - kd = "0.0" - else: - kd = round((kills / deaths), 3) - - skirmish_now = len(matches["skirmishes"]) - 1 - skirmish = format(matches["skirmishes"][skirmish_now]["scores"][worldcolor], ",d") - - kills = format(matches["kills"][worldcolor], ",d") - deaths = format(matches["deaths"][worldcolor], ",d") - - embed = discord.Embed(title=world_name, description=tier, color=color) - embed.add_field(name="Score", value=chat_formatting.inline(score)) - embed.add_field(name="Points per tick", value=chat_formatting.inline(ppt)) - embed.add_field(name="Victory Points", value=chat_formatting.inline(victoryp)) - embed.add_field(name="Skirmish", value=chat_formatting.inline(skirmish)) - embed.add_field(name="Kills", value=chat_formatting.inline(kills)) - embed.add_field(name="Deaths", value=chat_formatting.inline(deaths)) - embed.add_field(name="K/D ratio", value=chat_formatting.inline(str(kd))) - embed.add_field(name="Population", value=chat_formatting.inline(population), inline=False) - await progress_msg.delete() - await bot_utils.send_embed(ctx, embed) + wid = await _resolve_wvw_world_id(ctx, gw2_api, world, no_api_key_msg) + if not wid: + if world: + return await bot_utils.send_error_msg(ctx, f"{gw2_messages.INVALID_WORLD_NAME}\n{world}") return None - @wvw.command(name="match") - @commands.cooldown(1, GW2CoolDowns.Wvw.seconds, commands.BucketType.user) - async def match(self, ctx, *, world: str = None): - """Display WvW match scores. Defaults to your account's world. + progress_msg = await gw2_utils.send_progress_embed( + ctx, "Please wait, I'm fetching WvW info... (this may take a moment)" + ) - Usage: - gw2 wvw match - gw2 wvw match Blackgate - """ + try: + # Fetch match data and world info in parallel when possible + if is_wr_team_id(wid): + matches = await gw2_api.call_api(f"wvw/matches?world={wid}") + world_name = get_team_name(wid) or f"Team {wid}" + population = "N/A" + else: + matches, worldinfo = await asyncio.gather( + gw2_api.call_api(f"wvw/matches?world={wid}"), + gw2_api.call_api(f"worlds?id={wid}"), + ) + world_name = worldinfo["name"] + population = worldinfo["population"] + except Exception as e: + await progress_msg.delete() + await bot_utils.send_error_msg(ctx, e) + return ctx.bot.log.error(ctx, e) + + tier = _resolve_tier(matches) + + worldcolor = None + for key, value in matches["all_worlds"].items(): + if wid in value: + worldcolor = key + if not worldcolor: + return await bot_utils.send_error_msg(ctx, gw2_messages.WORLD_COLOR_ERROR) + + match worldcolor: + case "red": + color = discord.Color.red() + case "green": + color = discord.Color.green() + case "blue": + color = discord.Color.blue() + case _: + color = discord.Color.default() - await ctx.message.channel.typing() - gw2_api = Gw2Client(self.bot) + ppt = 0 + score = format(matches["scores"][worldcolor], ",d") + victoryp = matches["victory_points"][worldcolor] - no_key_msg = gw2_messages.MISSING_WORLD_NAME - no_key_msg += gw2_messages.match_world_name_help(ctx.prefix) - no_key_msg += gw2_messages.key_add_info_help(ctx.prefix) - no_key_msg += gw2_messages.key_more_info_help(ctx.prefix) + await ctx.message.channel.typing() + for m in matches["maps"]: + for objective in m["objectives"]: + if objective["owner"].lower() == worldcolor: + ppt += objective["points_tick"] - wid = await self._resolve_wvw_world_id(ctx, gw2_api, world, no_key_msg) - if not wid: - if world: - return await bot_utils.send_error_msg(ctx, f"{gw2_messages.INVALID_WORLD_NAME}: {world}") - return None + if population == "VeryHigh": + population = "Very high" - progress_msg = await gw2_utils.send_progress_embed( - ctx, "Please wait, I'm fetching WvW match data... (this may take a moment)" - ) + kills = matches["kills"][worldcolor] + deaths = matches["deaths"][worldcolor] - try: - matches = await gw2_api.call_api(f"wvw/matches?world={wid}") + if kills == 0 or deaths == 0: + kd = "0.0" + else: + kd = round((kills / deaths), 3) - tier = _resolve_tier(matches) - - ( - green_worlds_names, - blue_worlds_names, - red_worlds_names, - green_values, - blue_values, - red_values, - ) = await asyncio.gather( - _get_map_names_embed_values(ctx, "green", matches), - _get_map_names_embed_values(ctx, "blue", matches), - _get_map_names_embed_values(ctx, "red", matches), - _get_match_embed_values("green", matches), - _get_match_embed_values("blue", matches), - _get_match_embed_values("red", matches), - ) - except Exception as e: - await progress_msg.delete() - await bot_utils.send_error_msg(ctx, e) - return ctx.bot.log.error(ctx, e) - - color = self.bot.settings["gw2"]["EmbedColor"] - embed = discord.Embed(title="WvW Score", description=tier, color=color) - embed.add_field(name="Green", value=green_worlds_names) - embed.add_field(name="Blue", value=blue_worlds_names) - embed.add_field(name="Red", value=red_worlds_names) - embed.add_field(name="--------------------", value=green_values) - embed.add_field(name="--------------------", value=blue_values) - embed.add_field(name="--------------------", value=red_values) - await progress_msg.delete() - await bot_utils.send_embed(ctx, embed) + skirmish_now = len(matches["skirmishes"]) - 1 + skirmish = format(matches["skirmishes"][skirmish_now]["scores"][worldcolor], ",d") + + kills = format(matches["kills"][worldcolor], ",d") + deaths = format(matches["deaths"][worldcolor], ",d") + + embed = discord.Embed(title=world_name, description=tier, color=color) + embed.add_field(name="Score", value=chat_formatting.inline(score)) + embed.add_field(name="Points per tick", value=chat_formatting.inline(ppt)) + embed.add_field(name="Victory Points", value=chat_formatting.inline(victoryp)) + embed.add_field(name="Skirmish", value=chat_formatting.inline(skirmish)) + embed.add_field(name="Kills", value=chat_formatting.inline(kills)) + embed.add_field(name="Deaths", value=chat_formatting.inline(deaths)) + embed.add_field(name="K/D ratio", value=chat_formatting.inline(str(kd))) + embed.add_field(name="Population", value=chat_formatting.inline(population), inline=False) + await progress_msg.delete() + await bot_utils.send_embed(ctx, embed) + return None + + +@wvw.command(name="match") +@commands.cooldown(1, GW2CoolDowns.Wvw.seconds, commands.BucketType.user) +async def match(ctx, *, world: str = None): + """Display WvW match scores. Defaults to your account's world. + + Usage: + gw2 wvw match + gw2 wvw match Blackgate + """ + + await ctx.message.channel.typing() + gw2_api = Gw2Client(ctx.bot) + + no_key_msg = gw2_messages.MISSING_WORLD_NAME + no_key_msg += gw2_messages.match_world_name_help(ctx.prefix) + no_key_msg += gw2_messages.key_add_info_help(ctx.prefix) + no_key_msg += gw2_messages.key_more_info_help(ctx.prefix) + + wid = await _resolve_wvw_world_id(ctx, gw2_api, world, no_key_msg) + if not wid: + if world: + return await bot_utils.send_error_msg(ctx, f"{gw2_messages.INVALID_WORLD_NAME}: {world}") return None - @wvw.command(name="kdr") - @commands.cooldown(1, GW2CoolDowns.Wvw.seconds, commands.BucketType.user) - async def kdr(self, ctx, *, world: str = None): - """Display WvW kill/death ratios. Defaults to your account's world. + progress_msg = await gw2_utils.send_progress_embed( + ctx, "Please wait, I'm fetching WvW match data... (this may take a moment)" + ) - Usage: - gw2 wvw kdr - gw2 wvw kdr Blackgate - """ + try: + matches = await gw2_api.call_api(f"wvw/matches?world={wid}") - await ctx.message.channel.typing() - gw2_api = Gw2Client(self.bot) + tier = _resolve_tier(matches) - no_key_msg = gw2_messages.INVALID_WORLD_NAME - no_key_msg += gw2_messages.match_world_name_help(ctx.prefix) - no_key_msg += gw2_messages.key_add_info_help(ctx.prefix) - no_key_msg += gw2_messages.key_more_info_help(ctx.prefix) + ( + green_worlds_names, + blue_worlds_names, + red_worlds_names, + green_values, + blue_values, + red_values, + ) = await asyncio.gather( + _get_map_names_embed_values(ctx, "green", matches), + _get_map_names_embed_values(ctx, "blue", matches), + _get_map_names_embed_values(ctx, "red", matches), + _get_match_embed_values("green", matches), + _get_match_embed_values("blue", matches), + _get_match_embed_values("red", matches), + ) + except Exception as e: + await progress_msg.delete() + await bot_utils.send_error_msg(ctx, e) + return ctx.bot.log.error(ctx, e) + + color = ctx.bot.settings["gw2"]["EmbedColor"] + embed = discord.Embed(title="WvW Score", description=tier, color=color) + embed.add_field(name="Green", value=green_worlds_names) + embed.add_field(name="Blue", value=blue_worlds_names) + embed.add_field(name="Red", value=red_worlds_names) + embed.add_field(name="--------------------", value=green_values) + embed.add_field(name="--------------------", value=blue_values) + embed.add_field(name="--------------------", value=red_values) + await progress_msg.delete() + await bot_utils.send_embed(ctx, embed) + return None + + +@wvw.command(name="kdr") +@commands.cooldown(1, GW2CoolDowns.Wvw.seconds, commands.BucketType.user) +async def kdr(ctx, *, world: str = None): + """Display WvW kill/death ratios. Defaults to your account's world. + + Usage: + gw2 wvw kdr + gw2 wvw kdr Blackgate + """ + + await ctx.message.channel.typing() + gw2_api = Gw2Client(ctx.bot) + + no_key_msg = gw2_messages.INVALID_WORLD_NAME + no_key_msg += gw2_messages.match_world_name_help(ctx.prefix) + no_key_msg += gw2_messages.key_add_info_help(ctx.prefix) + no_key_msg += gw2_messages.key_more_info_help(ctx.prefix) + + wid = await _resolve_wvw_world_id(ctx, gw2_api, world, no_key_msg) + if not wid: + if world: + return await bot_utils.send_error_msg(ctx, f"{gw2_messages.INVALID_WORLD_NAME}: {world}") + return None - wid = await self._resolve_wvw_world_id(ctx, gw2_api, world, no_key_msg) - if not wid: - if world: - return await bot_utils.send_error_msg(ctx, f"{gw2_messages.INVALID_WORLD_NAME}: {world}") - return None + progress_msg = await gw2_utils.send_progress_embed( + ctx, "Please wait, I'm fetching WvW K/D data... (this may take a moment)" + ) - progress_msg = await gw2_utils.send_progress_embed( - ctx, "Please wait, I'm fetching WvW K/D data... (this may take a moment)" - ) + try: + matches = await gw2_api.call_api(f"wvw/matches?world={wid}") - try: - matches = await gw2_api.call_api(f"wvw/matches?world={wid}") + tier = _resolve_tier(matches) - tier = _resolve_tier(matches) - - ( - green_worlds_names, - blue_worlds_names, - red_worlds_names, - green_values, - blue_values, - red_values, - ) = await asyncio.gather( - _get_map_names_embed_values(ctx, "green", matches), - _get_map_names_embed_values(ctx, "blue", matches), - _get_map_names_embed_values(ctx, "red", matches), - _get_kdr_embed_values("green", matches), - _get_kdr_embed_values("blue", matches), - _get_kdr_embed_values("red", matches), - ) - except Exception as e: - await progress_msg.delete() - await bot_utils.send_error_msg(ctx, e) - return ctx.bot.log.error(ctx, e) - - color = self.bot.settings["gw2"]["EmbedColor"] - embed = discord.Embed(title=gw2_messages.WVW_KDR_TITLE, description=tier, color=color) - embed.add_field(name="Green", value=green_worlds_names) - embed.add_field(name="Blue", value=blue_worlds_names) - embed.add_field(name="Red", value=red_worlds_names) - embed.add_field(name="--------------------", value=green_values) - embed.add_field(name="--------------------", value=blue_values) - embed.add_field(name="--------------------", value=red_values) + ( + green_worlds_names, + blue_worlds_names, + red_worlds_names, + green_values, + blue_values, + red_values, + ) = await asyncio.gather( + _get_map_names_embed_values(ctx, "green", matches), + _get_map_names_embed_values(ctx, "blue", matches), + _get_map_names_embed_values(ctx, "red", matches), + _get_kdr_embed_values("green", matches), + _get_kdr_embed_values("blue", matches), + _get_kdr_embed_values("red", matches), + ) + except Exception as e: await progress_msg.delete() - await bot_utils.send_embed(ctx, embed) + await bot_utils.send_error_msg(ctx, e) + return ctx.bot.log.error(ctx, e) + + color = ctx.bot.settings["gw2"]["EmbedColor"] + embed = discord.Embed(title=gw2_messages.WVW_KDR_TITLE, description=tier, color=color) + embed.add_field(name="Green", value=green_worlds_names) + embed.add_field(name="Blue", value=blue_worlds_names) + embed.add_field(name="Red", value=red_worlds_names) + embed.add_field(name="--------------------", value=green_values) + embed.add_field(name="--------------------", value=blue_values) + embed.add_field(name="--------------------", value=red_values) + await progress_msg.delete() + await bot_utils.send_embed(ctx, embed) + return None + + +async def _resolve_wvw_world_id(ctx, gw2_api, world, error_msg): + """Resolve a WvW world/team ID from world name or account data. + + Returns the world/team ID, or None if resolution failed (error already sent). + """ + if world: + return await gw2_utils.get_world_id(ctx.bot, world) + + try: + gw2_key_dal = Gw2KeyDal(ctx.bot.db_session, ctx.bot.log) + rs = await gw2_key_dal.get_api_key_by_user(ctx.message.author.id) + if not rs: + await bot_utils.send_error_msg(ctx, error_msg) + return None + + api_key = rs[0]["key"] + results = await gw2_api.call_api("account", api_key) + # Prefer WR team_id over legacy world + return results.get("wvw", {}).get("team_id") or results["world"] + except APIKeyError: + await bot_utils.send_error_msg(ctx, error_msg) + return None + except Exception as e: + await bot_utils.send_error_msg(ctx, e) + ctx.bot.log.error(ctx, e) return None diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index 42ef3fb..d28dafa 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -30,7 +30,7 @@ def __init__(self): _gw2_settings = get_gw2_settings() _background_tasks: set[asyncio.Task] = set() -_processing_sessions: dict[int, str | None] = {} +_processing_sessions: dict[int, dict[str, str | None]] = {} _achievement_cache: dict[int, dict] = {} @@ -352,20 +352,25 @@ async def _handle_gw2_activity_change( """Handle GW2 activity changes and manage session tracking. Uses a per-user pending-action queue so that end events arriving while - a start is in progress are never silently dropped. + a start is in progress are never silently dropped. Duplicate events + (same action as current or already-pending) are dropped. """ if member.id in _processing_sessions: - _processing_sessions[member.id] = action + state = _processing_sessions[member.id] + if action == state["current"] or action == state["pending"]: + bot.log.debug(f"Duplicate '{action}' event for user {member.id}, ignoring") + return + state["pending"] = action bot.log.debug(f"Session operation in progress for user {member.id}, queuing '{action}' as pending") return - _processing_sessions[member.id] = None + _processing_sessions[member.id] = {"current": action, "pending": None} try: while action is not None: + _processing_sessions[member.id]["current"] = action await _execute_session_action(bot, member, action) - # Check if a new action was queued while we were processing - action = _processing_sessions[member.id] - _processing_sessions[member.id] = None + action = _processing_sessions[member.id]["pending"] + _processing_sessions[member.id]["pending"] = None if action is not None: bot.log.debug(f"Processing pending '{action}' action for user {member.id}") finally: @@ -411,10 +416,20 @@ async def start_session(bot: Bot, member: discord.Member, api_key: str) -> None: await _do_start_session(bot, member, api_key, session) +def _sort_session_dict(session: dict) -> dict: + """Sort session dict keys alphabetically with 'date' first.""" + date_value = session.pop("date", None) + sorted_dict = dict(sorted(session.items())) + if date_value is not None: + sorted_dict = {"date": date_value, **sorted_dict} + return sorted_dict + + async def _do_start_session(bot: Bot, member: discord.Member, api_key: str, session: dict) -> None: """Execute start session DB operations.""" session["user_id"] = member.id session["date"] = bot_utils.convert_datetime_to_str_short(bot_utils.get_current_date_time()) + session = _sort_session_dict(session) bot.log.debug(f"Attempting to insert start session into DB for user {member.id}") try: @@ -444,6 +459,7 @@ async def _do_end_session(bot: Bot, member: discord.Member, api_key: str, sessio """Execute end session DB operations.""" session["user_id"] = member.id session["date"] = bot_utils.convert_datetime_to_str_short(bot_utils.get_current_date_time()) + session = _sort_session_dict(session) bot.log.debug(f"Attempting to update end session in DB for user {member.id}") try: diff --git a/tests/unit/gw2/cogs/test_characters.py b/tests/unit/gw2/cogs/test_characters.py index 919edf6..3f068d1 100644 --- a/tests/unit/gw2/cogs/test_characters.py +++ b/tests/unit/gw2/cogs/test_characters.py @@ -62,6 +62,7 @@ def mock_ctx(self): ctx.message.author.display_avatar.url = "https://example.com/avatar.png" ctx.message.channel = MagicMock() ctx.message.channel.typing = AsyncMock() + ctx.typing = MagicMock(return_value=AsyncMock()) ctx.prefix = "!" ctx.send = AsyncMock() return ctx @@ -346,7 +347,7 @@ async def test_characters_triggers_typing_indicator(self, mock_ctx, sample_api_k mock_instance.get_api_key_by_user = AsyncMock(return_value=None) with patch("src.gw2.cogs.characters.bot_utils.send_error_msg"): await characters(mock_ctx) - mock_ctx.message.channel.typing.assert_called() + mock_ctx.typing.assert_called() @pytest.mark.asyncio async def test_characters_embed_has_thumbnail_and_author(self, mock_ctx, sample_api_key_data, sample_account_data): diff --git a/tests/unit/gw2/cogs/test_wvw.py b/tests/unit/gw2/cogs/test_wvw.py index 5d359b6..7f06aeb 100644 --- a/tests/unit/gw2/cogs/test_wvw.py +++ b/tests/unit/gw2/cogs/test_wvw.py @@ -8,7 +8,12 @@ _get_map_names_embed_values, _get_match_embed_values, _resolve_tier, + _resolve_wvw_world_id, + info, + kdr, + match, setup, + wvw, ) from src.gw2.constants import gw2_messages from src.gw2.tools.gw2_exceptions import APIKeyError @@ -148,8 +153,6 @@ def sample_worldinfo_data(self): @pytest.mark.asyncio async def test_info_no_world_no_api_key(self, mock_bot, mock_ctx): """Test info command with no world and no API key.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.Gw2KeyDal") as mock_dal: mock_instance = mock_dal.return_value @@ -157,7 +160,7 @@ async def test_info_no_world_no_api_key(self, mock_bot, mock_ctx): with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: with patch("src.gw2.cogs.wvw.Gw2Client"): - await cog.info.callback(cog, mock_ctx, world=None) + await info(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -166,8 +169,6 @@ async def test_info_no_world_no_api_key(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_info_no_world_api_key_exists(self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data): """Test info command with no world but API key exists, uses account's world.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key-12345"}] @@ -186,14 +187,12 @@ async def test_info_no_world_api_key_exists(self, mock_bot, mock_ctx, sample_mat ) with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.info.callback(cog, mock_ctx, world=None) + await info(mock_ctx, world=None) mock_send.assert_called_once() @pytest.mark.asyncio async def test_info_no_world_api_key_error(self, mock_bot, mock_ctx): """Test info command with no world and APIKeyError.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key-12345"}] @@ -206,7 +205,7 @@ async def test_info_no_world_api_key_error(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=APIKeyError(mock_ctx.bot, "Invalid key")) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.info.callback(cog, mock_ctx, world=None) + await info(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -215,8 +214,6 @@ async def test_info_no_world_api_key_error(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_info_no_world_generic_exception(self, mock_bot, mock_ctx): """Test info command with no world and generic exception.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key-12345"}] @@ -230,7 +227,7 @@ async def test_info_no_world_generic_exception(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=error) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.info.callback(cog, mock_ctx, world=None) + await info(mock_ctx, world=None) mock_error.assert_called_once_with(mock_ctx, error) mock_ctx.bot.log.error.assert_called_once() @@ -240,8 +237,6 @@ async def test_info_world_given_calls_get_world_id( self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data ): """Test info command with world given uses get_world_id.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -256,7 +251,7 @@ async def test_info_world_given_calls_get_world_id( ) with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") mock_get_wid.assert_called_once_with(mock_ctx.bot, "Anvil Rock") mock_send.assert_called_once() @@ -264,15 +259,13 @@ async def test_info_world_given_calls_get_world_id( @pytest.mark.asyncio async def test_info_wid_is_none(self, mock_bot, mock_ctx): """Test info command when wid is None.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = None with patch("src.gw2.cogs.wvw.Gw2Client"): with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.info.callback(cog, mock_ctx, world="InvalidWorld") + await info(mock_ctx, world="InvalidWorld") mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -282,8 +275,6 @@ async def test_info_wid_is_none(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_info_api_call_fails(self, mock_bot, mock_ctx): """Test info command when API calls for matches/worldinfo fail.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -294,7 +285,7 @@ async def test_info_api_call_fails(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=error) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") mock_error.assert_called_once_with(mock_ctx, error) mock_ctx.bot.log.error.assert_called_once() @@ -302,8 +293,6 @@ async def test_info_api_call_fails(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_info_world_color_not_found(self, mock_bot, mock_ctx, sample_worldinfo_data): """Test info command when world color is not found.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot matches_data = { "id": "1-3", @@ -327,7 +316,7 @@ async def test_info_world_color_not_found(self, mock_bot, mock_ctx, sample_world ) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -336,8 +325,6 @@ async def test_info_world_color_not_found(self, mock_bot, mock_ctx, sample_world @pytest.mark.asyncio async def test_info_na_tier(self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data): """Test info command with NA tier (wid < 2001).""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 # NA world @@ -352,7 +339,7 @@ async def test_info_na_tier(self, mock_bot, mock_ctx, sample_matches_data, sampl ) with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] assert gw2_messages.NA_TIER_TITLE in embed.description @@ -360,8 +347,6 @@ async def test_info_na_tier(self, mock_bot, mock_ctx, sample_matches_data, sampl @pytest.mark.asyncio async def test_info_eu_tier(self, mock_bot, mock_ctx, sample_worldinfo_data): """Test info command with EU tier (wid >= 2001).""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot eu_matches_data = { "id": "2-5", @@ -402,7 +387,7 @@ async def test_info_eu_tier(self, mock_bot, mock_ctx, sample_worldinfo_data): ) with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.info.callback(cog, mock_ctx, world="Desolation") + await info(mock_ctx, world="Desolation") embed = mock_send.call_args[0][1] assert gw2_messages.EU_TIER_TITLE in embed.description @@ -410,8 +395,6 @@ async def test_info_eu_tier(self, mock_bot, mock_ctx, sample_worldinfo_data): @pytest.mark.asyncio async def test_info_red_world_color(self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data): """Test info command with red world color.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 # In red all_worlds @@ -426,7 +409,7 @@ async def test_info_red_world_color(self, mock_bot, mock_ctx, sample_matches_dat ) with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] assert embed.color == discord.Color.red() @@ -434,8 +417,6 @@ async def test_info_red_world_color(self, mock_bot, mock_ctx, sample_matches_dat @pytest.mark.asyncio async def test_info_green_world_color(self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data): """Test info command with green world color.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1003 # In green all_worlds @@ -450,7 +431,7 @@ async def test_info_green_world_color(self, mock_bot, mock_ctx, sample_matches_d ) with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.info.callback(cog, mock_ctx, world="Some World") + await info(mock_ctx, world="Some World") embed = mock_send.call_args[0][1] assert embed.color == discord.Color.green() @@ -458,8 +439,6 @@ async def test_info_green_world_color(self, mock_bot, mock_ctx, sample_matches_d @pytest.mark.asyncio async def test_info_blue_world_color(self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data): """Test info command with blue world color.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1005 # In blue all_worlds @@ -474,7 +453,7 @@ async def test_info_blue_world_color(self, mock_bot, mock_ctx, sample_matches_da ) with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.info.callback(cog, mock_ctx, world="Some World") + await info(mock_ctx, world="Some World") embed = mock_send.call_args[0][1] assert embed.color == discord.Color.blue() @@ -482,8 +461,6 @@ async def test_info_blue_world_color(self, mock_bot, mock_ctx, sample_matches_da @pytest.mark.asyncio async def test_info_population_veryhigh(self, mock_bot, mock_ctx, sample_matches_data): """Test info command with VeryHigh population is converted to 'Very high'.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot worldinfo_veryhigh = { "id": 1001, @@ -505,7 +482,7 @@ async def test_info_population_veryhigh(self, mock_bot, mock_ctx, sample_matches with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: with patch("src.gw2.cogs.wvw.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] # Check that the Population field has "Very high" @@ -515,8 +492,6 @@ async def test_info_population_veryhigh(self, mock_bot, mock_ctx, sample_matches @pytest.mark.asyncio async def test_info_kills_zero_kd(self, mock_bot, mock_ctx, sample_worldinfo_data): """Test info command when kills=0, kd should be '0.0'.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot matches_zero_kills = { "id": "1-3", @@ -551,7 +526,7 @@ async def test_info_kills_zero_kd(self, mock_bot, mock_ctx, sample_worldinfo_dat with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: with patch("src.gw2.cogs.wvw.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] kd_field = next(f for f in embed.fields if f.name == "K/D ratio") @@ -560,8 +535,6 @@ async def test_info_kills_zero_kd(self, mock_bot, mock_ctx, sample_worldinfo_dat @pytest.mark.asyncio async def test_info_deaths_zero_kd(self, mock_bot, mock_ctx, sample_worldinfo_data): """Test info command when deaths=0, kd should be '0.0'.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot matches_zero_deaths = { "id": "1-3", @@ -596,7 +569,7 @@ async def test_info_deaths_zero_kd(self, mock_bot, mock_ctx, sample_worldinfo_da with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: with patch("src.gw2.cogs.wvw.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] kd_field = next(f for f in embed.fields if f.name == "K/D ratio") @@ -605,8 +578,6 @@ async def test_info_deaths_zero_kd(self, mock_bot, mock_ctx, sample_worldinfo_da @pytest.mark.asyncio async def test_info_normal_kd_calculation(self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data): """Test info command with normal kd calculation.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 # red world @@ -622,7 +593,7 @@ async def test_info_normal_kd_calculation(self, mock_bot, mock_ctx, sample_match with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: with patch("src.gw2.cogs.wvw.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] kd_field = next(f for f in embed.fields if f.name == "K/D ratio") @@ -632,8 +603,6 @@ async def test_info_normal_kd_calculation(self, mock_bot, mock_ctx, sample_match @pytest.mark.asyncio async def test_info_successful_embed_sent(self, mock_bot, mock_ctx, sample_matches_data, sample_worldinfo_data): """Test info command sends successful embed with all fields.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -649,7 +618,7 @@ async def test_info_successful_embed_sent(self, mock_bot, mock_ctx, sample_match with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: with patch("src.gw2.cogs.wvw.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") mock_send.assert_called_once() embed = mock_send.call_args[0][1] @@ -756,8 +725,6 @@ def sample_matches_data(self): @pytest.mark.asyncio async def test_match_no_world_no_api_key(self, mock_bot, mock_ctx): """Test match command with no world and no API key shows help message.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.Gw2KeyDal") as mock_dal: mock_instance = mock_dal.return_value @@ -765,7 +732,7 @@ async def test_match_no_world_no_api_key(self, mock_bot, mock_ctx): with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: with patch("src.gw2.cogs.wvw.Gw2Client"): - await cog.match.callback(cog, mock_ctx, world=None) + await match(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -774,8 +741,6 @@ async def test_match_no_world_no_api_key(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_match_no_world_api_key_error(self, mock_bot, mock_ctx): """Test match command with no world and APIKeyError.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key-12345"}] @@ -788,7 +753,7 @@ async def test_match_no_world_api_key_error(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=APIKeyError(mock_ctx.bot, "Invalid key")) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.match.callback(cog, mock_ctx, world=None) + await match(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -797,8 +762,6 @@ async def test_match_no_world_api_key_error(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_match_no_world_generic_exception(self, mock_bot, mock_ctx): """Test match command with no world and generic exception.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key-12345"}] @@ -812,7 +775,7 @@ async def test_match_no_world_generic_exception(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=error) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.match.callback(cog, mock_ctx, world=None) + await match(mock_ctx, world=None) mock_error.assert_called_once_with(mock_ctx, error) mock_ctx.bot.log.error.assert_called_once() @@ -820,8 +783,6 @@ async def test_match_no_world_generic_exception(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_match_world_given_uses_get_world_id(self, mock_bot, mock_ctx, sample_matches_data): """Test match command with world given uses get_world_id.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -834,22 +795,20 @@ async def test_match_world_given_uses_get_world_id(self, mock_bot, mock_ctx, sam mock_pop.return_value = ["World1 (High)", "World2 (Medium)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.match.callback(cog, mock_ctx, world="Anvil Rock") + await match(mock_ctx, world="Anvil Rock") mock_get_wid.assert_called_once_with(mock_ctx.bot, "Anvil Rock") @pytest.mark.asyncio async def test_match_wid_none(self, mock_bot, mock_ctx): """Test match command when wid is None.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = None with patch("src.gw2.cogs.wvw.Gw2Client"): with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.match.callback(cog, mock_ctx, world="InvalidWorld") + await match(mock_ctx, world="InvalidWorld") mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -859,8 +818,6 @@ async def test_match_wid_none(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_match_na_tier(self, mock_bot, mock_ctx, sample_matches_data): """Test match command with NA tier.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -873,7 +830,7 @@ async def test_match_na_tier(self, mock_bot, mock_ctx, sample_matches_data): mock_pop.return_value = ["World1 (High)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.match.callback(cog, mock_ctx, world="Anvil Rock") + await match(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] assert gw2_messages.NA_TIER_TITLE in embed.description @@ -881,8 +838,6 @@ async def test_match_na_tier(self, mock_bot, mock_ctx, sample_matches_data): @pytest.mark.asyncio async def test_match_eu_tier(self, mock_bot, mock_ctx): """Test match command with EU tier.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot eu_matches = { "id": "2-5", @@ -934,7 +889,7 @@ async def test_match_eu_tier(self, mock_bot, mock_ctx): mock_pop.return_value = ["World1 (High)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.match.callback(cog, mock_ctx, world="Desolation") + await match(mock_ctx, world="Desolation") embed = mock_send.call_args[0][1] assert gw2_messages.EU_TIER_TITLE in embed.description @@ -942,8 +897,6 @@ async def test_match_eu_tier(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_match_exception_during_fetch(self, mock_bot, mock_ctx): """Test match command when exception during matches fetch.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -954,7 +907,7 @@ async def test_match_exception_during_fetch(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=error) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.match.callback(cog, mock_ctx, world="Anvil Rock") + await match(mock_ctx, world="Anvil Rock") mock_error.assert_called_once_with(mock_ctx, error) mock_ctx.bot.log.error.assert_called_once() @@ -962,8 +915,6 @@ async def test_match_exception_during_fetch(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_match_successful_embed(self, mock_bot, mock_ctx, sample_matches_data): """Test match command sends successful embed with green/blue/red values.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -976,7 +927,7 @@ async def test_match_successful_embed(self, mock_bot, mock_ctx, sample_matches_d mock_pop.return_value = ["World1 (High)", "World2 (Medium)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.match.callback(cog, mock_ctx, world="Anvil Rock") + await match(mock_ctx, world="Anvil Rock") mock_send.assert_called_once() embed = mock_send.call_args[0][1] @@ -1068,8 +1019,6 @@ def sample_matches_data(self): @pytest.mark.asyncio async def test_kdr_no_world_no_api_key(self, mock_bot, mock_ctx): """Test kdr command with no world and no API key shows error message.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.Gw2KeyDal") as mock_dal: mock_instance = mock_dal.return_value @@ -1077,7 +1026,7 @@ async def test_kdr_no_world_no_api_key(self, mock_bot, mock_ctx): with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: with patch("src.gw2.cogs.wvw.Gw2Client"): - await cog.kdr.callback(cog, mock_ctx, world=None) + await kdr(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -1086,8 +1035,6 @@ async def test_kdr_no_world_no_api_key(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_kdr_no_world_api_key_error(self, mock_bot, mock_ctx): """Test kdr command with no world and APIKeyError.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key-12345"}] @@ -1100,7 +1047,7 @@ async def test_kdr_no_world_api_key_error(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=APIKeyError(mock_ctx.bot, "Invalid key")) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.kdr.callback(cog, mock_ctx, world=None) + await kdr(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -1109,8 +1056,6 @@ async def test_kdr_no_world_api_key_error(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_kdr_no_world_generic_exception(self, mock_bot, mock_ctx): """Test kdr command with no world and generic exception.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key-12345"}] @@ -1124,7 +1069,7 @@ async def test_kdr_no_world_generic_exception(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=error) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.kdr.callback(cog, mock_ctx, world=None) + await kdr(mock_ctx, world=None) mock_error.assert_called_once_with(mock_ctx, error) mock_ctx.bot.log.error.assert_called_once() @@ -1132,8 +1077,6 @@ async def test_kdr_no_world_generic_exception(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_kdr_world_given(self, mock_bot, mock_ctx, sample_matches_data): """Test kdr command with world given uses get_world_id.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -1146,22 +1089,20 @@ async def test_kdr_world_given(self, mock_bot, mock_ctx, sample_matches_data): mock_pop.return_value = ["World1 (High)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.kdr.callback(cog, mock_ctx, world="Anvil Rock") + await kdr(mock_ctx, world="Anvil Rock") mock_get_wid.assert_called_once_with(mock_ctx.bot, "Anvil Rock") @pytest.mark.asyncio async def test_kdr_wid_none(self, mock_bot, mock_ctx): """Test kdr command when wid is None.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = None with patch("src.gw2.cogs.wvw.Gw2Client"): with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.kdr.callback(cog, mock_ctx, world="InvalidWorld") + await kdr(mock_ctx, world="InvalidWorld") mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -1171,8 +1112,6 @@ async def test_kdr_wid_none(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_kdr_na_tier_title(self, mock_bot, mock_ctx, sample_matches_data): """Test kdr command with NA tier title.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -1185,7 +1124,7 @@ async def test_kdr_na_tier_title(self, mock_bot, mock_ctx, sample_matches_data): mock_pop.return_value = ["World1 (High)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.kdr.callback(cog, mock_ctx, world="Anvil Rock") + await kdr(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] assert gw2_messages.NA_TIER_TITLE in embed.description @@ -1193,8 +1132,6 @@ async def test_kdr_na_tier_title(self, mock_bot, mock_ctx, sample_matches_data): @pytest.mark.asyncio async def test_kdr_eu_tier_title(self, mock_bot, mock_ctx): """Test kdr command with EU tier title.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot eu_matches = { "id": "2-5", @@ -1246,7 +1183,7 @@ async def test_kdr_eu_tier_title(self, mock_bot, mock_ctx): mock_pop.return_value = ["World1 (High)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.kdr.callback(cog, mock_ctx, world="Desolation") + await kdr(mock_ctx, world="Desolation") embed = mock_send.call_args[0][1] assert "Europe" in embed.description @@ -1255,8 +1192,6 @@ async def test_kdr_eu_tier_title(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_kdr_exception_during_fetch(self, mock_bot, mock_ctx): """Test kdr command when exception during matches fetch.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -1267,7 +1202,7 @@ async def test_kdr_exception_during_fetch(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=error) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - await cog.kdr.callback(cog, mock_ctx, world="Anvil Rock") + await kdr(mock_ctx, world="Anvil Rock") mock_error.assert_called_once_with(mock_ctx, error) mock_ctx.bot.log.error.assert_called_once() @@ -1275,8 +1210,6 @@ async def test_kdr_exception_during_fetch(self, mock_bot, mock_ctx): @pytest.mark.asyncio async def test_kdr_successful_embed(self, mock_bot, mock_ctx, sample_matches_data): """Test kdr command sends successful embed.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock) as mock_get_wid: mock_get_wid.return_value = 1001 @@ -1289,7 +1222,7 @@ async def test_kdr_successful_embed(self, mock_bot, mock_ctx, sample_matches_dat mock_pop.return_value = ["World1 (High)"] with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: - await cog.kdr.callback(cog, mock_ctx, world="Anvil Rock") + await kdr(mock_ctx, world="Anvil Rock") mock_send.assert_called_once() embed = mock_send.call_args[0][1] @@ -1787,10 +1720,8 @@ def mock_ctx(self): @pytest.mark.asyncio async def test_wvw_group_calls_invoke_subcommand(self, mock_bot, mock_ctx): """Test that wvw group command calls invoke_subcommand (line 27).""" - cog = GW2WvW(mock_bot) - with patch("src.gw2.cogs.wvw.bot_utils.invoke_subcommand", new_callable=AsyncMock) as mock_invoke: - await cog.wvw.callback(cog, mock_ctx) + await wvw(mock_ctx) mock_invoke.assert_called_once_with(mock_ctx, "gw2 wvw") @@ -1829,8 +1760,6 @@ def mock_ctx(self): @pytest.mark.asyncio async def test_info_unknown_world_color_uses_default(self, mock_bot, mock_ctx): """Test info command uses discord.Color.default() for unexpected worldcolor (lines 89-90).""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot sample_worldinfo = {"id": 1001, "name": "Anvil Rock", "population": "High"} @@ -1857,7 +1786,7 @@ async def test_info_unknown_world_color_uses_default(self, mock_bot, mock_ctx): with patch("src.gw2.cogs.wvw.bot_utils.send_embed") as mock_send: with patch("src.gw2.cogs.wvw.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - await cog.info.callback(cog, mock_ctx, world="Anvil Rock") + await info(mock_ctx, world="Anvil Rock") embed = mock_send.call_args[0][1] assert embed.color == discord.Color.default() @@ -1898,8 +1827,6 @@ def mock_ctx(self): @pytest.mark.asyncio async def test_match_api_key_error_on_account_call(self, mock_bot, mock_ctx): """Test match command sends NO_API_KEY error when APIKeyError is raised during account call (line 159-161).""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key"}] @@ -1913,7 +1840,7 @@ async def test_match_api_key_error_on_account_call(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=APIKeyError(mock_ctx.bot, "Invalid key")) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - result = await cog.match.callback(cog, mock_ctx, world=None) + result = await match(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -1955,8 +1882,6 @@ def mock_ctx(self): @pytest.mark.asyncio async def test_kdr_api_key_error_on_account_call(self, mock_bot, mock_ctx): """Test kdr command sends NO_API_KEY error when APIKeyError is raised during account call (line 227-229).""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot api_key_data = [{"key": "test-api-key"}] @@ -1969,7 +1894,7 @@ async def test_kdr_api_key_error_on_account_call(self, mock_bot, mock_ctx): mock_client_instance.call_api = AsyncMock(side_effect=APIKeyError(mock_ctx.bot, "Invalid key")) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - result = await cog.kdr.callback(cog, mock_ctx, world=None) + result = await kdr(mock_ctx, world=None) mock_error.assert_called_once() error_msg = mock_error.call_args[0][1] @@ -2034,62 +1959,52 @@ def mock_ctx(self): @pytest.mark.asyncio async def test_with_world_name_delegates_to_get_world_id(self, mock_bot, mock_ctx): """Test that providing a world name uses get_world_id.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot gw2_api = MagicMock() with patch("src.gw2.cogs.wvw.gw2_utils.get_world_id", new_callable=AsyncMock, return_value=1001): - result = await cog._resolve_wvw_world_id(mock_ctx, gw2_api, "Anvil Rock", "error msg") + result = await _resolve_wvw_world_id(mock_ctx, gw2_api, "Anvil Rock", "error msg") assert result == 1001 @pytest.mark.asyncio async def test_prefers_wvw_team_id_over_world(self, mock_bot, mock_ctx): """Test that wvw.team_id is preferred over legacy world field.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot gw2_api = MagicMock() gw2_api.call_api = AsyncMock(return_value={"world": 1001, "wvw": {"team_id": 11005}}) with patch("src.gw2.cogs.wvw.Gw2KeyDal") as mock_dal: mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=[{"key": "test-key"}]) - result = await cog._resolve_wvw_world_id(mock_ctx, gw2_api, None, "error msg") + result = await _resolve_wvw_world_id(mock_ctx, gw2_api, None, "error msg") assert result == 11005 @pytest.mark.asyncio async def test_falls_back_to_world_when_no_team_id(self, mock_bot, mock_ctx): """Test fallback to world when wvw.team_id is absent.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot gw2_api = MagicMock() gw2_api.call_api = AsyncMock(return_value={"world": 1001}) with patch("src.gw2.cogs.wvw.Gw2KeyDal") as mock_dal: mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=[{"key": "test-key"}]) - result = await cog._resolve_wvw_world_id(mock_ctx, gw2_api, None, "error msg") + result = await _resolve_wvw_world_id(mock_ctx, gw2_api, None, "error msg") assert result == 1001 @pytest.mark.asyncio async def test_no_api_key_sends_error(self, mock_bot, mock_ctx): """Test that missing API key sends error and returns None.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot gw2_api = MagicMock() with patch("src.gw2.cogs.wvw.Gw2KeyDal") as mock_dal: mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=None) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - result = await cog._resolve_wvw_world_id(mock_ctx, gw2_api, None, "no key msg") + result = await _resolve_wvw_world_id(mock_ctx, gw2_api, None, "no key msg") assert result is None mock_error.assert_called_once() @pytest.mark.asyncio async def test_api_key_error_sends_error(self, mock_bot, mock_ctx): """Test that APIKeyError sends error message.""" - cog = GW2WvW(mock_bot) - cog.bot = mock_ctx.bot gw2_api = MagicMock() gw2_api.call_api = AsyncMock(side_effect=APIKeyError(mock_ctx.bot, "bad key")) @@ -2097,6 +2012,6 @@ async def test_api_key_error_sends_error(self, mock_bot, mock_ctx): mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=[{"key": "test-key"}]) with patch("src.gw2.cogs.wvw.bot_utils.send_error_msg") as mock_error: - result = await cog._resolve_wvw_world_id(mock_ctx, gw2_api, None, "error msg") + result = await _resolve_wvw_world_id(mock_ctx, gw2_api, None, "error msg") assert result is None mock_error.assert_called_once() diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index d6f633d..11015f0 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -17,6 +17,7 @@ _is_gw2_activity_detected, _processing_sessions, _retry_session_later, + _sort_session_dict, _update_achievement_stats, _update_wallet_stats, calculate_user_achiev_points, @@ -821,7 +822,7 @@ async def side_effect(bot, member, action): call_count += 1 if call_count == 1: # Simulate end event arriving during start processing - _processing_sessions[member.id] = "end" + _processing_sessions[member.id]["pending"] = "end" mock_execute.side_effect = side_effect @@ -833,8 +834,8 @@ async def side_effect(bot, member, action): assert mock_member.id not in _processing_sessions @pytest.mark.asyncio - async def test_duplicate_during_processing_queues_latest(self, mock_bot, mock_member): - """Test that only the latest pending action is kept when multiple arrive during processing.""" + async def test_different_pending_overwrites_previous(self, mock_bot, mock_member): + """Test that a different pending action overwrites a previous one.""" with patch("src.gw2.tools.gw2_utils._execute_session_action") as mock_execute: call_count = 0 @@ -842,30 +843,59 @@ async def side_effect(bot, member, action): nonlocal call_count call_count += 1 if call_count == 1: - # Simulate: start queued, then end queued (end wins) - _processing_sessions[member.id] = "start" - _processing_sessions[member.id] = "end" + # Simulate: end queued during start processing + _processing_sessions[member.id]["pending"] = "end" mock_execute.side_effect = side_effect await _handle_gw2_activity_change(mock_bot, mock_member, "start") assert mock_execute.call_count == 2 - # Second call should be "end" (last queued action) + # Second call should be "end" (pending action) mock_execute.assert_any_call(mock_bot, mock_member, "end") assert mock_member.id not in _processing_sessions @pytest.mark.asyncio - async def test_concurrent_call_queues_instead_of_dropping(self, mock_bot, mock_member): - """Test that a concurrent call queues the action instead of dropping it.""" - # Simulate a session already being processed - _processing_sessions[mock_member.id] = None + async def test_concurrent_call_queues_different_action(self, mock_bot, mock_member): + """Test that a concurrent call with a different action queues it.""" + # Simulate a start session already being processed + _processing_sessions[mock_member.id] = {"current": "start", "pending": None} try: await _handle_gw2_activity_change(mock_bot, mock_member, "end") - # The action should be queued, not dropped - assert _processing_sessions[mock_member.id] == "end" + # The end action should be queued as pending + assert _processing_sessions[mock_member.id]["pending"] == "end" + finally: + _processing_sessions.pop(mock_member.id, None) + + @pytest.mark.asyncio + async def test_duplicate_current_action_is_dropped(self, mock_bot, mock_member): + """Test that a duplicate of the currently running action is dropped.""" + # Simulate a start session already being processed + _processing_sessions[mock_member.id] = {"current": "start", "pending": None} + + try: + await _handle_gw2_activity_change(mock_bot, mock_member, "start") + + # Duplicate should be ignored, pending stays None + assert _processing_sessions[mock_member.id]["pending"] is None + mock_bot.log.debug.assert_called_with(f"Duplicate 'start' event for user {mock_member.id}, ignoring") + finally: + _processing_sessions.pop(mock_member.id, None) + + @pytest.mark.asyncio + async def test_duplicate_pending_action_is_dropped(self, mock_bot, mock_member): + """Test that a duplicate of an already-pending action is dropped.""" + # Simulate start running with end already pending + _processing_sessions[mock_member.id] = {"current": "start", "pending": "end"} + + try: + await _handle_gw2_activity_change(mock_bot, mock_member, "end") + + # Duplicate should be ignored, pending stays "end" + assert _processing_sessions[mock_member.id]["pending"] == "end" + mock_bot.log.debug.assert_called_with(f"Duplicate 'end' event for user {mock_member.id}, ignoring") finally: _processing_sessions.pop(mock_member.id, None) @@ -1671,6 +1701,37 @@ def test_time_object_init(self): assert obj.seconds == 0 +class TestSortSessionDict: + """Test cases for _sort_session_dict function.""" + + def test_date_first_then_alphabetical(self): + """Test that date is first, remaining keys sorted alphabetically.""" + session = {"gold": 100, "date": "2026-01-01 00:00:00", "acc_name": "Test", "karma": 50} + result = _sort_session_dict(session) + keys = list(result.keys()) + assert keys[0] == "date" + assert keys[1:] == sorted(keys[1:]) + + def test_no_date_key(self): + """Test sorting when no date key exists.""" + session = {"gold": 100, "acc_name": "Test", "karma": 50} + result = _sort_session_dict(session) + assert list(result.keys()) == ["acc_name", "gold", "karma"] + + def test_preserves_values(self): + """Test that values are preserved after sorting.""" + session = {"date": "2026-01-01", "gold": 100, "acc_name": "Test"} + result = _sort_session_dict(session) + assert result["date"] == "2026-01-01" + assert result["gold"] == 100 + assert result["acc_name"] == "Test" + + def test_empty_dict(self): + """Test with empty dict.""" + result = _sort_session_dict({}) + assert result == {} + + class TestDeleteApiKey: """Test cases for delete_api_key function.""" diff --git a/uv.lock b/uv.lock index 2930a98..15977d2 100644 --- a/uv.lock +++ b/uv.lock @@ -737,15 +737,15 @@ wheels = [ [[package]] name = "poethepoet" -version = "0.42.0" +version = "0.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pastel" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/9a/4e81fafef2ba94e5c974b4701343d1f053a27575ab5133cbd264348925dd/poethepoet-0.42.0.tar.gz", hash = "sha256:c9a2828259e585e9ed152857602130ff339f7b1638879b80d4a23f25588be4f8", size = 91278, upload-time = "2026-02-22T14:24:50.967Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/9b/e717572686bbf23e17483389c1bf3a381ca2427c84c7e0af0cdc0f23fccc/poethepoet-0.42.1.tar.gz", hash = "sha256:205747e276062c2aaba8afd8a98838f8a3a0237b7ab94715fab8d82718aac14f", size = 93209, upload-time = "2026-02-26T22:57:50.883Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/3e/58041b7e4d49b69e859dc81c35e221cf02d91ed4dbb5a2f6cc4698a29f44/poethepoet-0.42.0-py3-none-any.whl", hash = "sha256:e43cc20d458ee5bfccaa4572bc5783bcb93991a7d2fcf8dadc9c43f1ebc9b277", size = 118091, upload-time = "2026-02-22T14:24:49.53Z" }, + { url = "https://files.pythonhosted.org/packages/c8/68/75fa0a5ef39718ea6ba7ab6a3d031fa93640e57585580cec85539540bb65/poethepoet-0.42.1-py3-none-any.whl", hash = "sha256:d8d1345a5ca521be9255e7c13bc2c4c8698ed5e5ac5e9e94890d239fcd423d0a", size = 119967, upload-time = "2026-02-26T22:57:49.467Z" }, ] [[package]]