88from textwrap import dedent
99from typing import Literal , NamedTuple , TYPE_CHECKING , get_args
1010
11- from discord import AllowedMentions , HTTPException , Interaction , Message , NotFound , Reaction , User , enums , ui
11+ from discord import (
12+ AllowedMentions ,
13+ HTTPException ,
14+ Interaction ,
15+ Message ,
16+ NotFound ,
17+ Reaction ,
18+ User ,
19+ enums ,
20+ ui ,
21+ )
1222from discord .ext .commands import Cog , Command , Context , Converter , command , guild_only
23+ from discord .ui import Button
1324from pydis_core .utils import interactions , paste_service
1425from pydis_core .utils .paste_service import PasteFile , send_to_paste_service
1526from pydis_core .utils .regex import FORMATTED_CODE_REGEX , RAW_CODE_REGEX
@@ -82,13 +93,21 @@ def print_last_line():
8293# The Snekbox commands' whitelists and blacklists.
8394NO_SNEKBOX_CHANNELS = (Channels .python_general ,)
8495NO_SNEKBOX_CATEGORIES = ()
85- SNEKBOX_ROLES = (Roles .helpers , Roles .moderators , Roles .admins , Roles .owners , Roles .python_community , Roles .partners )
96+ SNEKBOX_ROLES = (
97+ Roles .helpers ,
98+ Roles .moderators ,
99+ Roles .admins ,
100+ Roles .owners ,
101+ Roles .python_community ,
102+ Roles .partners ,
103+ )
86104
87105REDO_EMOJI = "\U0001f501 " # :repeat:
88106REDO_TIMEOUT = 30
89107
90108SupportedPythonVersions = Literal ["3.12" , "3.13" , "3.13t" ]
91109
110+
92111class FilteredFiles (NamedTuple ):
93112 allowed : list [FileAttachment ]
94113 blocked : list [FileAttachment ]
@@ -119,7 +138,9 @@ async def convert(cls, ctx: Context, code: str) -> list[str]:
119138 code , block , lang , delim = match .group ("code" , "block" , "lang" , "delim" )
120139 codeblocks = [dedent (code )]
121140 if block :
122- info = (f"'{ lang } ' highlighted" if lang else "plain" ) + " code block"
141+ info = (
142+ f"'{ lang } ' highlighted" if lang else "plain"
143+ ) + " code block"
123144 else :
124145 info = f"{ delim } -enclosed inline code"
125146 else :
@@ -142,7 +163,9 @@ def __init__(
142163 job : EvalJob ,
143164 ) -> None :
144165 self .version_to_run = version_to_run
145- super ().__init__ (label = f"Run in { self .version_to_run } " , style = enums .ButtonStyle .primary )
166+ super ().__init__ (
167+ label = f"Run in { self .version_to_run } " , style = enums .ButtonStyle .primary
168+ )
146169
147170 self .snekbox_cog = snekbox_cog
148171 self .ctx = ctx
@@ -163,7 +186,9 @@ async def callback(self, interaction: Interaction) -> None:
163186 # The log arg on send_job will stop the actual job from running.
164187 await interaction .message .delete ()
165188
166- await self .snekbox_cog .run_job (self .ctx , self .job .as_version (self .version_to_run ))
189+ await self .snekbox_cog .run_job (
190+ self .ctx , self .job .as_version (self .version_to_run )
191+ )
167192
168193
169194class Snekbox (Cog ):
@@ -197,7 +222,9 @@ async def post_job(self, job: EvalJob) -> EvalResult:
197222 """Send a POST request to the Snekbox API to evaluate code and return the results."""
198223 data = job .to_dict ()
199224
200- async with self .bot .http_session .post (URLs .snekbox_eval_api , json = data , raise_for_status = True ) as resp :
225+ async with self .bot .http_session .post (
226+ URLs .snekbox_eval_api , json = data , raise_for_status = True
227+ ) as resp :
201228 return EvalResult .from_dict (await resp .json ())
202229
203230 async def upload_output (self , output : str ) -> str | None :
@@ -257,7 +284,10 @@ async def format_output(
257284
258285 if ESCAPE_REGEX .findall (output ):
259286 paste_link = await self .upload_output (original_output )
260- return "Code block escape attempt detected; will not output result" , paste_link
287+ return (
288+ "Code block escape attempt detected; will not output result" ,
289+ paste_link ,
290+ )
261291
262292 truncated = False
263293 lines = output .splitlines ()
@@ -269,12 +299,14 @@ async def format_output(
269299 if len (lines ) > max_lines :
270300 truncated = True
271301 if len (lines ) == max_lines + 1 :
272- lines = lines [:max_lines - 1 ]
302+ lines = lines [: max_lines - 1 ]
273303 else :
274304 lines = lines [:max_lines ]
275305 output = "\n " .join (lines )
276306 if len (output ) >= max_chars :
277- output = f"{ output [:max_chars ]} \n ... (truncated - too long, too many lines)"
307+ output = (
308+ f"{ output [:max_chars ]} \n ... (truncated - too long, too many lines)"
309+ )
278310 else :
279311 output = f"{ output } \n ... (truncated - too many lines)"
280312 elif len (output ) >= max_chars :
@@ -292,7 +324,9 @@ async def format_output(
292324
293325 return output , paste_link
294326
295- async def format_file_text (self , text_files : list [FileAttachment ], output : str ) -> str :
327+ async def format_file_text (
328+ self , text_files : list [FileAttachment ], output : str
329+ ) -> str :
296330 # Inline until budget, then upload to paste service
297331 # Budget is shared with stdout, so subtract what we've already used
298332 budget_lines = MAX_OUTPUT_BLOCK_LINES - (output .count ("\n " ) + 1 )
@@ -311,7 +345,7 @@ async def format_file_text(self, text_files: list[FileAttachment], output: str)
311345 budget_lines ,
312346 budget_chars ,
313347 line_nums = False ,
314- output_default = "[Empty]"
348+ output_default = "[Empty]" ,
315349 )
316350 # With any link, use it (don't use budget)
317351 if link_text :
@@ -325,24 +359,30 @@ async def format_file_text(self, text_files: list[FileAttachment], output: str)
325359
326360 def format_blocked_extensions (self , blocked : list [FileAttachment ]) -> str :
327361 # Sort by length and then lexicographically to fit as many as possible before truncating.
328- blocked_sorted = sorted (set (f .suffix for f in blocked ), key = lambda e : (len (e ), e ))
362+ blocked_sorted = sorted (
363+ set (f .suffix for f in blocked ), key = lambda e : (len (e ), e )
364+ )
329365
330366 # Only no extension
331367 if len (blocked_sorted ) == 1 and blocked_sorted [0 ] == "" :
332368 blocked_msg = "Files with no extension can't be uploaded."
333369 # Both
334370 elif "" in blocked_sorted :
335- blocked_str = self .join_blocked_extensions (ext for ext in blocked_sorted if ext )
336- blocked_msg = (
337- f"Files with no extension or disallowed extensions can't be uploaded: **{ blocked_str } **"
371+ blocked_str = self .join_blocked_extensions (
372+ ext for ext in blocked_sorted if ext
338373 )
374+ blocked_msg = f"Files with no extension or disallowed extensions can't be uploaded: **{ blocked_str } **"
339375 else :
340376 blocked_str = self .join_blocked_extensions (blocked_sorted )
341- blocked_msg = f"Files with disallowed extensions can't be uploaded: **{ blocked_str } **"
377+ blocked_msg = (
378+ f"Files with disallowed extensions can't be uploaded: **{ blocked_str } **"
379+ )
342380
343381 return f"\n { Emojis .failed_file } { blocked_msg } "
344382
345- def join_blocked_extensions (self , extensions : Iterable [str ], delimiter : str = ", " , char_limit : int = 100 ) -> str :
383+ def join_blocked_extensions (
384+ self , extensions : Iterable [str ], delimiter : str = ", " , char_limit : int = 100
385+ ) -> str :
346386 joined = ""
347387 for ext in extensions :
348388 cur_delimiter = delimiter if joined else ""
@@ -354,8 +394,9 @@ def join_blocked_extensions(self, extensions: Iterable[str], delimiter: str = ",
354394
355395 return joined
356396
357-
358- def _filter_files (self , ctx : Context , files : list [FileAttachment ], blocked_exts : set [str ]) -> FilteredFiles :
397+ def _filter_files (
398+ self , ctx : Context , files : list [FileAttachment ], blocked_exts : set [str ]
399+ ) -> FilteredFiles :
359400 """Filter to restrict files to allowed extensions. Return a named tuple of allowed and blocked files lists."""
360401 # Filter files into allowed and blocked
361402 blocked = []
@@ -370,7 +411,7 @@ def _filter_files(self, ctx: Context, files: list[FileAttachment], blocked_exts:
370411 blocked_str = ", " .join (f .suffix for f in blocked )
371412 log .info (
372413 f"User '{ ctx .author } ' ({ ctx .author .id } ) uploaded blacklisted file(s) in eval: { blocked_str } " ,
373- extra = {"attachment_list" : [f .filename for f in files ]}
414+ extra = {"attachment_list" : [f .filename for f in files ]},
374415 )
375416
376417 return FilteredFiles (allowed , blocked )
@@ -399,16 +440,18 @@ async def send_job(self, ctx: Context, job: EvalJob) -> Message:
399440
400441 # This is done to make sure the last line of output contains the error
401442 # and the error is not manually printed by the author with a syntax error.
402- if result .stdout .rstrip ().endswith ("EOFError: EOF when reading a line" ) and result .returncode == 1 :
403- msg += "\n :warning: Note: `input` is not supported by the bot :warning:\n "
443+ if (
444+ result .stdout .rstrip ().endswith ("EOFError: EOF when reading a line" )
445+ and result .returncode == 1
446+ ):
447+ msg += (
448+ "\n :warning: Note: `input` is not supported by the bot :warning:\n "
449+ )
404450
405451 # Skip output if it's empty and there are file uploads
406452 if result .stdout or not result .has_files :
407453 msg += f"\n ```ansi\n { output } \n ```"
408454
409- if paste_link :
410- msg += f"\n Full output: { paste_link } "
411-
412455 # Additional files error message after output
413456 if files_error := result .files_error_message :
414457 msg += f"\n { files_error } "
@@ -423,9 +466,13 @@ async def send_job(self, ctx: Context, job: EvalJob) -> Message:
423466 failed_files = [FileAttachment (name , b"" ) for name in result .failed_files ]
424467 total_files = result .files + failed_files
425468 if filter_cog :
426- block_output , blocked_exts = await filter_cog .filter_snekbox_output (msg , total_files , ctx .message )
469+ block_output , blocked_exts = await filter_cog .filter_snekbox_output (
470+ msg , total_files , ctx .message
471+ )
427472 if block_output :
428- return await ctx .send ("Attempt to circumvent filter detected. Moderator team has been alerted." )
473+ return await ctx .send (
474+ "Attempt to circumvent filter detected. Moderator team has been alerted."
475+ )
429476
430477 # Filter file extensions
431478 allowed , blocked = self ._filter_files (ctx , result .files , blocked_exts )
@@ -435,8 +482,18 @@ async def send_job(self, ctx: Context, job: EvalJob) -> Message:
435482
436483 # Upload remaining non-text files
437484 files = [f .to_file () for f in allowed if f not in text_files ]
438- allowed_mentions = AllowedMentions (everyone = False , roles = False , users = [ctx .author ])
485+ allowed_mentions = AllowedMentions (
486+ everyone = False , roles = False , users = [ctx .author ]
487+ )
439488 view = self .build_python_version_switcher_view (job .version , ctx , job )
489+ if paste_link :
490+ # Create a button
491+ button = Button (
492+ label = "View Full Output" , # Button text
493+ url = paste_link , # The URL the button links to
494+ )
495+
496+ view .add_item (button )
440497
441498 if ctx .message .channel == ctx .channel :
442499 # Don't fail if the command invoking message was deleted.
@@ -446,15 +503,19 @@ async def send_job(self, ctx: Context, job: EvalJob) -> Message:
446503 allowed_mentions = allowed_mentions ,
447504 view = view ,
448505 files = files ,
449- reference = message
506+ reference = message ,
450507 )
451508 else :
452509 # The command was redirected so a reply wont work, send a normal message with a mention.
453510 msg = f"{ ctx .author .mention } { msg } "
454- response = await ctx .send (msg , allowed_mentions = allowed_mentions , view = view , files = files )
511+ response = await ctx .send (
512+ msg , allowed_mentions = allowed_mentions , view = view , files = files
513+ )
455514 view .message = response
456515
457- log .info (f"{ ctx .author } 's { job .name } job had a return code of { result .returncode } " )
516+ log .info (
517+ f"{ ctx .author } 's { job .name } job had a return code of { result .returncode } "
518+ )
458519 return response
459520
460521 async def continue_job (
@@ -472,15 +533,11 @@ async def continue_job(
472533 with contextlib .suppress (NotFound ):
473534 try :
474535 _ , new_message = await self .bot .wait_for (
475- "message_edit" ,
476- check = _predicate_message_edit ,
477- timeout = REDO_TIMEOUT
536+ "message_edit" , check = _predicate_message_edit , timeout = REDO_TIMEOUT
478537 )
479538 await ctx .message .add_reaction (REDO_EMOJI )
480539 await self .bot .wait_for (
481- "reaction_add" ,
482- check = _predicate_emoji_reaction ,
483- timeout = 10
540+ "reaction_add" , check = _predicate_emoji_reaction , timeout = 10
484541 )
485542
486543 # Ensure the response that's about to be edited is still the most recent.
@@ -576,14 +633,14 @@ async def run_job(
576633 bypass_roles = SNEKBOX_ROLES ,
577634 categories = NO_SNEKBOX_CATEGORIES ,
578635 channels = NO_SNEKBOX_CHANNELS ,
579- ping_user = False
636+ ping_user = False ,
580637 )
581638 async def eval_command (
582639 self ,
583640 ctx : Context ,
584641 python_version : SupportedPythonVersions | None ,
585642 * ,
586- code : CodeblockConverter
643+ code : CodeblockConverter ,
587644 ) -> None :
588645 """
589646 Run Python code and get the results.
@@ -608,21 +665,25 @@ async def eval_command(
608665 job = EvalJob .from_code ("\n " .join (code )).as_version (python_version )
609666 await self .run_job (ctx , job )
610667
611- @command (name = "timeit" , aliases = ("ti" ,), usage = "[python_version] [setup_code] <code, ...>" )
668+ @command (
669+ name = "timeit" ,
670+ aliases = ("ti" ,),
671+ usage = "[python_version] [setup_code] <code, ...>" ,
672+ )
612673 @guild_only ()
613674 @redirect_output (
614675 destination_channel = Channels .bot_commands ,
615676 bypass_roles = SNEKBOX_ROLES ,
616677 categories = NO_SNEKBOX_CATEGORIES ,
617678 channels = NO_SNEKBOX_CHANNELS ,
618- ping_user = False
679+ ping_user = False ,
619680 )
620681 async def timeit_command (
621682 self ,
622683 ctx : Context ,
623684 python_version : SupportedPythonVersions | None ,
624685 * ,
625- code : CodeblockConverter
686+ code : CodeblockConverter ,
626687 ) -> None :
627688 """
628689 Profile Python Code to find execution time.
@@ -654,4 +715,8 @@ def predicate_message_edit(ctx: Context, old_msg: Message, new_msg: Message) ->
654715
655716def predicate_emoji_reaction (ctx : Context , reaction : Reaction , user : User ) -> bool :
656717 """Return True if the reaction REDO_EMOJI was added by the context message author on this message."""
657- return reaction .message .id == ctx .message .id and user .id == ctx .author .id and str (reaction ) == REDO_EMOJI
718+ return (
719+ reaction .message .id == ctx .message .id
720+ and user .id == ctx .author .id
721+ and str (reaction ) == REDO_EMOJI
722+ )
0 commit comments