Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bot/exts/utils/snekbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from bot.bot import Bot
from bot.exts.utils.snekbox._cog import CodeblockConverter, Snekbox
from bot.exts.utils.snekbox._cog import CodeblockConverter, Snekbox, SupportedPythonVersions
from bot.exts.utils.snekbox._eval import EvalJob, EvalResult

__all__ = ("CodeblockConverter", "EvalJob", "EvalResult", "Snekbox")
__all__ = ("CodeblockConverter", "EvalJob", "EvalResult", "Snekbox", "SupportedPythonVersions")


async def setup(bot: Bot) -> None:
Expand Down
47 changes: 26 additions & 21 deletions bot/exts/utils/snekbox/_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def print_last_line():
REDO_EMOJI = "\U0001f501" # :repeat:
REDO_TIMEOUT = 30

SupportedPythonVersions = Literal["3.12", "3.13", "3.13t"]
SupportedPythonVersions = Literal["3.13", "3.13t", "3.14"]

class FilteredFiles(NamedTuple):
allowed: list[FileAttachment]
Expand Down Expand Up @@ -569,7 +569,29 @@ async def run_job(
break
log.info(f"Re-evaluating code from message {ctx.message.id}:\n{job}")

@command(name="eval", aliases=("e",), usage="[python_version] <code, ...>")
@command(
name="eval",
aliases=("e",),
usage="[python_version] <code, ...>",
help=f"""
Run Python code and get the results.

This command supports multiple lines of code, including formatted code blocks.
Code can be re-evaluated by editing the original message within 10 seconds and
clicking the reaction that subsequently appears.

The starting working directory `/home`, is a writeable temporary file system.
Files created, excluding names with leading underscores, will be uploaded in the response.

If multiple codeblocks are in a message, all of them will be joined and evaluated,
ignoring the text outside them.

The currently supported versions are {", ".join(get_args(SupportedPythonVersions))}.

We've done our best to make this sandboxed, but do let us know if you manage to find an
issue with it!
"""
)
@guild_only()
@redirect_output(
destination_channel=Channels.bot_commands,
Expand All @@ -585,26 +607,9 @@ async def eval_command(
*,
code: CodeblockConverter
) -> None:
"""
Run Python code and get the results.

This command supports multiple lines of code, including formatted code blocks.
Code can be re-evaluated by editing the original message within 10 seconds and
clicking the reaction that subsequently appears.

The starting working directory `/home`, is a writeable temporary file system.
Files created, excluding names with leading underscores, will be uploaded in the response.

If multiple codeblocks are in a message, all of them will be joined and evaluated,
ignoring the text outside them.

The currently supported verisons are 3.12, 3.13, and 3.13t.

We've done our best to make this sandboxed, but do let us know if you manage to find an
issue with it!
"""
"""Run Python code and get the results."""
code: list[str]
python_version = python_version or "3.12"
python_version = python_version or get_args(SupportedPythonVersions)[0]
job = EvalJob.from_code("\n".join(code)).as_version(python_version)
await self.run_job(ctx, job)

Expand Down
9 changes: 7 additions & 2 deletions bot/exts/utils/snekbox/_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class EvalJob:
args: list[str]
files: list[FileAttachment] = field(default_factory=list)
name: str = "eval"
version: SupportedPythonVersions = "3.12"
version: SupportedPythonVersions = "3.13"

@classmethod
def from_code(cls, code: str, path: str = "main.py") -> EvalJob:
Expand Down Expand Up @@ -144,7 +144,12 @@ def get_failed_files_str(self, char_max: int = 85) -> str:

def get_status_message(self, job: EvalJob) -> str:
"""Return a user-friendly message corresponding to the process's return code."""
version_text = job.version.replace("t", " [free threaded](<https://docs.python.org/3.13/whatsnew/3.13.html#free-threaded-cpython>)")
if job.version == "3.13t":
version_text = job.version.replace("t", " [free threaded](<https://docs.python.org/3.13/whatsnew/3.13.html#free-threaded-cpython>)")
elif job.version == "3.14":
version_text = "3.14 [pre-release](<https://docs.python.org/3.14/whatsnew/3.14.html#development>)"
else:
version_text = job.version
msg = f"Your {version_text} {job.name} job"

if self.returncode is None:
Expand Down
38 changes: 22 additions & 16 deletions tests/bot/exts/utils/snekbox/test_snekbox.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import unittest
from base64 import b64encode
from typing import get_args
from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch

from discord import AllowedMentions
Expand All @@ -10,7 +11,7 @@
from bot import constants
from bot.errors import LockedResourceError
from bot.exts.utils import snekbox
from bot.exts.utils.snekbox import EvalJob, EvalResult, Snekbox
from bot.exts.utils.snekbox import EvalJob, EvalResult, Snekbox, SupportedPythonVersions
from bot.exts.utils.snekbox._io import FileAttachment
from tests.helpers import MockBot, MockContext, MockMember, MockMessage, MockReaction, MockUser

Expand All @@ -21,6 +22,7 @@ def setUp(self):
self.bot = MockBot()
self.cog = Snekbox(bot=self.bot)
self.job = EvalJob.from_code("import random")
self.default_version = get_args(SupportedPythonVersions)[0]

@staticmethod
def code_args(code: str) -> tuple[EvalJob]:
Expand All @@ -35,7 +37,7 @@ async def test_post_job(self):
context_manager = MagicMock()
context_manager.__aenter__.return_value = resp
self.bot.http_session.post.return_value = context_manager
py_version = "3.12"
py_version = self.default_version
job = EvalJob.from_code("import random").as_version(py_version)
self.assertEqual(await self.cog.post_job(job), EvalResult("Hi", 137))

Expand Down Expand Up @@ -104,9 +106,13 @@ def test_prepare_timeit_input(self):
def test_eval_result_message(self):
"""EvalResult.get_message(), should return message."""
cases = (
("ERROR", None, ("Your 3.12 eval job has failed", "ERROR", "")),
("", 128 + snekbox._eval.SIGKILL, ("Your 3.12 eval job timed out or ran out of memory", "", "")),
("", 255, ("Your 3.12 eval job has failed", "A fatal NsJail error occurred", ""))
("ERROR", None, (f"Your {self.default_version} eval job has failed", "ERROR", "")),
(
"",
128 + snekbox._eval.SIGKILL,
(f"Your {self.default_version} eval job timed out or ran out of memory", "", "")
),
("", 255, (f"Your {self.default_version} eval job has failed", "A fatal NsJail error occurred", ""))
)
for stdout, returncode, expected in cases:
exp_msg, exp_err, exp_files_err = expected
Expand Down Expand Up @@ -178,8 +184,8 @@ def test_eval_result_message_valid_signal(self, mock_signals: Mock):
mock_signals.return_value.name = "SIGTEST"
result = EvalResult(stdout="", returncode=127)
self.assertEqual(
result.get_status_message(EvalJob([], version="3.12")),
"Your 3.12 eval job has completed with return code 127 (SIGTEST)"
result.get_status_message(EvalJob([])),
f"Your {self.default_version} eval job has completed with return code 127 (SIGTEST)"
)

def test_eval_result_status_emoji(self):
Expand Down Expand Up @@ -253,7 +259,7 @@ async def test_eval_command_evaluate_once(self):
self.cog.send_job = AsyncMock(return_value=response)
self.cog.continue_job = AsyncMock(return_value=None)

await self.cog.eval_command(self.cog, ctx=ctx, python_version="3.12", code=["MyAwesomeCode"])
await self.cog.eval_command(self.cog, ctx=ctx, python_version=self.default_version, code=["MyAwesomeCode"])
job = EvalJob.from_code("MyAwesomeCode")
self.cog.send_job.assert_called_once_with(ctx, job)
self.cog.continue_job.assert_called_once_with(ctx, response, "eval")
Expand All @@ -267,7 +273,7 @@ async def test_eval_command_evaluate_twice(self):
self.cog.continue_job = AsyncMock()
self.cog.continue_job.side_effect = (EvalJob.from_code("MyAwesomeFormattedCode"), None)

await self.cog.eval_command(self.cog, ctx=ctx, python_version="3.12", code=["MyAwesomeCode"])
await self.cog.eval_command(self.cog, ctx=ctx, python_version=self.default_version, code=["MyAwesomeCode"])

expected_job = EvalJob.from_code("MyAwesomeFormattedCode")
self.cog.send_job.assert_called_with(ctx, expected_job)
Expand Down Expand Up @@ -311,7 +317,7 @@ async def test_send_job(self):
ctx.send.assert_called_once()
self.assertEqual(
ctx.send.call_args.args[0],
":warning: Your 3.12 eval job has completed "
f":warning: Your {self.default_version} eval job has completed "
"with return code 0.\n\n```ansi\n[No output]\n```"
)
allowed_mentions = ctx.send.call_args.kwargs["allowed_mentions"]
Expand All @@ -335,13 +341,13 @@ async def test_send_job_with_paste_link(self):
mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, []))
self.bot.get_cog.return_value = mocked_filter_cog

job = EvalJob.from_code("MyAwesomeCode").as_version("3.12")
job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version)
await self.cog.send_job(ctx, job),

ctx.send.assert_called_once()
self.assertEqual(
ctx.send.call_args.args[0],
":white_check_mark: Your 3.12 eval job "
f":white_check_mark: Your {self.default_version} eval job "
"has completed with return code 0."
"\n\n```ansi\nWay too long beard\n```\nFull output: lookatmybeard.com"
)
Expand All @@ -362,13 +368,13 @@ async def test_send_job_with_non_zero_eval(self):
mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, []))
self.bot.get_cog.return_value = mocked_filter_cog

job = EvalJob.from_code("MyAwesomeCode").as_version("3.12")
job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version)
await self.cog.send_job(ctx, job),

ctx.send.assert_called_once()
self.assertEqual(
ctx.send.call_args.args[0],
":x: Your 3.12 eval job has completed with return code 127."
f":x: Your {self.default_version} eval job has completed with return code 127."
"\n\n```ansi\nERROR\n```"
)

Expand All @@ -395,13 +401,13 @@ async def test_send_job_with_disallowed_file_ext(self):
mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, disallowed_exts))
self.bot.get_cog.return_value = mocked_filter_cog

job = EvalJob.from_code("MyAwesomeCode").as_version("3.12")
job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version)
await self.cog.send_job(ctx, job),

ctx.send.assert_called_once()
res = ctx.send.call_args.args[0]
self.assertTrue(
res.startswith(":white_check_mark: Your 3.12 eval job has completed with return code 0.")
res.startswith(f":white_check_mark: Your {self.default_version} eval job has completed with return code 0.")
)
self.assertIn("Files with disallowed extensions can't be uploaded: **.disallowed, .disallowed2, ...**", res)

Expand Down