From 8735c2bbab3540681856e8c51bbf82e2a8f34ec1 Mon Sep 17 00:00:00 2001 From: Lev Bernstein Date: Sun, 14 Dec 2025 09:54:39 -0500 Subject: [PATCH] Additional docstring coverage, dependency updates --- Bot.py | 103 +++++++++++++++++++++++---- bucks.py | 2 +- logs.py | 11 +-- misc.py | 28 ++++++-- resources/images/docstr-coverage.svg | 4 +- resources/requirements.txt | 6 +- unitTests.sh | 26 +++---- 7 files changed, 132 insertions(+), 48 deletions(-) diff --git a/Bot.py b/Bot.py index 229d70d..aa97467 100644 --- a/Bot.py +++ b/Bot.py @@ -71,7 +71,7 @@ async def on_ready() -> None: triggering an HTTPException. The method also initializes sparPings to enable a 2-hour cooldown for the - spar command, and chunks all guilds (caches them) to speed up operations. + spar command, and chunks (caches) all guilds to speed up operations. This also allows you to get a good idea of how many unique users are in all guilds in which Beardless Bot operates. """ @@ -91,7 +91,7 @@ async def on_ready() -> None: logger.info("Avatar updated!") if len(BeardlessBot.guilds) == 0: - logger.exception("Bot is in no servers! Add it to a server.") + logger.warning("Bot is in no servers! Add it to a server.") else: for guild in BeardlessBot.guilds: # Do this first so all servers can spar immediately @@ -457,6 +457,16 @@ async def cmd_dice(ctx: misc.BotContext) -> int | nextcord.Embed: @BeardlessBot.command(name="reset") async def cmd_reset(ctx: misc.BotContext) -> int: + """ + Reset the user to 200 BeardlessBucks and inform them of this. + + Args: + ctx (misc.BotContext): The context in which the command was invoked + + Returns: + int: -1 if the message was a thread creation; 1 otherwise. + + """ if misc.ctx_created_thread(ctx): return -1 await ctx.send(embed=bucks.reset(ctx.author)) @@ -465,6 +475,18 @@ async def cmd_reset(ctx: misc.BotContext) -> int: @BeardlessBot.command(name="register") async def cmd_register(ctx: misc.BotContext) -> int: + """ + Attempt to register the user with 300 BeardlessBucks. + + If the user is already registered, send a message to that effect. + + Args: + ctx (misc.BotContext): The context in which the command was invoked + + Returns: + int: -1 if the message was a thread creation; 1 otherwise. + + """ if misc.ctx_created_thread(ctx): return -1 await ctx.send(embed=bucks.register(ctx.author)) @@ -487,6 +509,16 @@ async def cmd_bucks(ctx: misc.BotContext) -> int | nextcord.Embed: @BeardlessBot.command(name="hello", aliases=("hi",)) async def cmd_hello(ctx: misc.BotContext) -> int: + """ + Send a random greeting. + + Args: + ctx (misc.BotContext): The context in which the command was invoked + + Returns: + int: -1 if the message was a thread creation; 1 otherwise. + + """ if misc.ctx_created_thread(ctx): return -1 await ctx.send(random.choice(misc.Greetings)) @@ -495,6 +527,16 @@ async def cmd_hello(ctx: misc.BotContext) -> int: @BeardlessBot.command(name="source") async def cmd_source(ctx: misc.BotContext) -> int: + """ + Send an embed containing the source of many of the fun facts. + + Args: + ctx (misc.BotContext): The context in which the command was invoked + + Returns: + int: -1 if the message was a thread creation; 1 otherwise. + + """ if misc.ctx_created_thread(ctx): return -1 source = ( @@ -534,6 +576,16 @@ async def cmd_random_brawl( @BeardlessBot.command(name="fact") async def cmd_fact(ctx: misc.BotContext) -> int: + """ + Send an embed containing a random fun fact. + + Args: + ctx (misc.BotContext): The context in which the command was invoked + + Returns: + int: -1 if the message was a thread creation; 1 otherwise. + + """ if misc.ctx_created_thread(ctx): return -1 await ctx.send(embed=misc.bb_embed( @@ -817,6 +869,14 @@ async def cmd_buy( return 1 +@BeardlessBot.command(name="search", aliases=("google", "lmgtfy")) +async def cmd_search(ctx: misc.BotContext, *, searchterm: str = "") -> int: + if misc.ctx_created_thread(ctx): + return -1 + await ctx.send(embed=misc.search(searchterm)) + return 1 + + @BeardlessBot.command(name="pins", aliases=("sparpins", "howtospar")) async def cmd_pins(ctx: misc.BotContext) -> int: if ( @@ -1036,6 +1096,17 @@ async def cmd_tweet(ctx: misc.BotContext) -> int: @BeardlessBot.command(name="reddit") async def cmd_reddit(ctx: misc.BotContext) -> int: + """ + Send a link to the EggSoup Subreddit, if in the Egg server. + + Args: + ctx (misc.BotContext): The context in which the command was invoked + + Returns: + int: -1 if the message was a thread creation; 0 if the command was not + invoked in the Egg server; 1 otherwise. + + """ if misc.ctx_created_thread(ctx) or not ctx.guild: return -1 if ctx.guild.id == EggGuildId: @@ -1046,6 +1117,17 @@ async def cmd_reddit(ctx: misc.BotContext) -> int: @BeardlessBot.command(name="guide") async def cmd_guide(ctx: misc.BotContext) -> int: + """ + Send a link to the EggSoup Improvement Guide, if in the Egg server. + + Args: + ctx (misc.BotContext): The context in which the command was invoked + + Returns: + int: -1 if the message was a thread creation; 0 if the command was not + invoked in the Egg server; 1 otherwise. + + """ if misc.ctx_created_thread(ctx) or not ctx.guild: return -1 if ctx.guild.id == EggGuildId: @@ -1057,14 +1139,6 @@ async def cmd_guide(ctx: misc.BotContext) -> int: return 0 -@BeardlessBot.command(name="search", aliases=("google", "lmgtfy")) -async def cmd_search(ctx: misc.BotContext, *, searchterm: str = "") -> int: - if misc.ctx_created_thread(ctx): - return -1 - await ctx.send(embed=misc.search(searchterm)) - return 1 - - # Listeners: @@ -1087,12 +1161,12 @@ async def on_command_error( misc.logException method. Args: - ctx (misc.botContext): The context in which the command threw an - Exception + ctx (misc.botContext): The context in which the command threw + an Exception e (commands.errors.CommandError): The Exception that was thrown Returns: - int: 0 if the Exception was CommandNotFound; otherwise, 0. + int: 0 if the Exception was CommandNotFound; otherwise, 1. """ if isinstance(e, commands.CommandNotFound): @@ -1186,7 +1260,8 @@ def launch() -> None: ], ) - # HTTPX tends to flood logs with INFO-level calls; set it to >= WARNING + # Tendency to flood logs with INFO-level calls; set them to >= WARNING logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("nextcord").setLevel(logging.WARNING) launch() diff --git a/bucks.py b/bucks.py index 2c8a77d..0de79e9 100644 --- a/bucks.py +++ b/bucks.py @@ -198,7 +198,7 @@ def deal_to_player(self) -> str: f" bringing your total to {sum(self.hand)}. " ) if BlackjackGame.AceVal in self.hand and self.check_bust(): - for i, card in enumerate(self.hand): + for i, card in enumerate(self.hand): # pragma: no branch if card == BlackjackGame.AceVal: self.hand[i] = 1 self.bet *= -1 diff --git a/logs.py b/logs.py index 72e3471..9d96838 100644 --- a/logs.py +++ b/logs.py @@ -7,9 +7,6 @@ from misc import ProfUrl, bb_embed, content_check, fetch_avatar, truncate_time -# TODO: Implement log thread locked/unlocked -# https://github.com/LevBernstein/BeardlessBot/issues/45 - MaxPurgedMsgs: Final[int] = 99 @@ -140,13 +137,9 @@ def log_member_nick_change( ).set_author( name=after.name, icon_url=fetch_avatar(after), ).add_field( - name="Before:", - value=before.nick if before.nick else before.name, - inline=False, + name="Before:", value=before.nick or before.name, inline=False, ).add_field( - name="After:", - value=after.nick if after.nick else after.name, - inline=False, + name="After:", value=after.nick or after.name, inline=False, ) diff --git a/misc.py b/misc.py index a0fc73f..cbf09e5 100644 --- a/misc.py +++ b/misc.py @@ -262,7 +262,7 @@ def fetch_avatar( user: nextcord.Member | nextcord.User | nextcord.ClientUser, ) -> str: """ - Pull a given user's avatar url. + Pull a given user's avatar url safely. Args: user (nextcord.Member or User or ClientUser): The user whose avatar @@ -296,6 +296,29 @@ def __init__(self, *, animal: str) -> None: async def fetch_animal(url: str, *args: str | int) -> str | None: + """ + Pull an animal image URL from a JSON response. + + For each arg in *args, move a layer deeper into the JSON response, + eventually returning the image URL. For example, calling fetch_animal + with args=["foo", 0, "bar", "url"] will extract the url from the following + expected JSON object: + + {"foo": ["bar": {"url": "animal.url"}]} + + This obviously fails horribly if the JSON response in any way does not + match what you are expecting. + + Args: + url (str): The API endpoint to call + *args (str | int): The path to take through the JSON response in order + to obtain the animal image URL. + + Returns: + str | None: The animal image URL if the called API returned OK; + else, None. + + """ async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url) if r.status_code == Ok: @@ -966,8 +989,6 @@ def get_last_numeric_char(duration: str) -> int: async def process_mute_target( ctx: BotContext, target: str | None, bot: commands.Bot, ) -> nextcord.Member | None: - # TODO: unit test - # https://github.com/LevBernstein/BeardlessBot/issues/47 assert hasattr(ctx.author, "guild_permissions") if not ctx.author.guild_permissions.manage_messages: await ctx.send(Naughty.format(ctx.author.mention)) @@ -1085,7 +1106,6 @@ async def check_for_spar_channel(ctx: BotContext) -> bool: # Static embeds. -# TODO: convert these to methods AdminPermsReasons = ( "Beardless Bot requires permissions in order to do just about anything." diff --git a/resources/images/docstr-coverage.svg b/resources/images/docstr-coverage.svg index baf70df..935903b 100644 --- a/resources/images/docstr-coverage.svg +++ b/resources/images/docstr-coverage.svg @@ -14,7 +14,7 @@ docstr-coverage docstr-coverage - 33% - 33% + 37% + 37% \ No newline at end of file diff --git a/resources/requirements.txt b/resources/requirements.txt index 2b243b9..0517b9a 100644 --- a/resources/requirements.txt +++ b/resources/requirements.txt @@ -3,7 +3,7 @@ aiohttp==3.13.2 audioop-lts==0.2.2; python_version>="3.13" beautifulsoup4==4.14.3 codespell==2.4.1 -coverage==7.12.0 +coverage==7.13.0 docstr-coverage==2.3.2 flake8==7.3.0 flake8-comprehensions==3.17.0 @@ -12,14 +12,14 @@ httpx==0.28.1 mypy[faster-cache, reports]==1.19.0 mypy-extensions==1.1.0 nextcord==3.1.1 -pytest==9.0.1 +pytest==9.0.2 pytest-asyncio==1.3.0 pytest-github-actions-annotate-failures==0.3.0 pytest-httpx==0.36.0 pytest-rerunfailures==16.1 python-dotenv==1.2.1 requests==2.32.5 -ruff==0.14.8 +ruff==0.14.9 steam==1.4.4 types-aiofiles==25.1.0.20251011 types-beautifulsoup4==4.12.0.20250516 diff --git a/unitTests.sh b/unitTests.sh index 1b6619e..e1c2bd9 100755 --- a/unitTests.sh +++ b/unitTests.sh @@ -4,22 +4,19 @@ #output results to stdout and testResults.md echo "# Beardless Bot Unit Test Results" > testResults.md; -python3 -m coverage run --include=$(echo $PWD)/* --omit=./*env/* \ +python3 -m coverage run --branch --include=$(echo $PWD)/* --omit=./*env/* \ -m pytest -W ignore::DeprecationWarning --ignore=./*env/* --tb=line \ --junitxml=junit.xml; python3 -m coverage report -m --precision=2 |& tee -a testResults.md; -python3 -m coverage xml 1> /dev/null; +python3 -m coverage xml -q; rm resources/images/coverage.svg 2> /dev/null; -genbadge coverage -i coverage.xml -o \ - resources/images/coverage.svg >> /dev/null; -rm .coverage 2> /dev/null; -rm coverage.xml 2> /dev/null; -python3 -m flake8 --exclude="*env/*" --ignore=A003,W191,W503 --statistics \ +genbadge coverage -s -i coverage.xml -o resources/images/coverage.svg; +rm .coverage coverage.xml 2> /dev/null; +python3 -m flake8 --exclude="*env/*" --ignore=W191,W503 --statistics \ --exit-zero --output-file flake8stats.txt --min-python-version 3.12.0; -genbadge flake8 -i flake8stats.txt -o \ - resources/images/flake8-badge.svg >> /dev/null; +genbadge flake8 -s -i flake8stats.txt -o resources/images/flake8-badge.svg; rm flake8stats.txt 2> /dev/null; -genbadge tests -i junit.xml -o resources/images/tests.svg >> /dev/null; +genbadge tests -s -i junit.xml -o resources/images/tests.svg; rm junit.xml 2> /dev/null; docstr-coverage ./ -e ".*bb_test.py/*|.*env/*" -v 0 --badge \ resources/images/docstr-coverage.svg 2> /dev/null; @@ -27,8 +24,7 @@ docstr-coverage ./ -e ".*bb_test.py/*|.*env/*" -v 0 --badge \ #Truncate decimals in coverage badge. Very hacky; I wish #there was an arg for significant figures. python3 -c ' -import re -with open("resources/images/coverage.svg") as f: - svg = f.read() -with open("resources/images/coverage.svg", "w") as g: - g.write(re.sub(r"((\.\d{2})%+)", ".00%", svg))' +import re, pathlib +p = pathlib.Path("resources/images/coverage.svg") +with p.open("r") as f: svg = f.read() +with p.open("w") as g: g.write(re.sub(r"((\.\d{2})%+)", ".00%", svg))'