1+ import asyncio
2+ from dataclasses import asdict
3+ from datetime import datetime
4+ from re import sub
5+ import time
6+ from typing import Any , Optional , Tuple
17import requests
2- from fastapi import HTTPException
3-
8+ from fastapi import HTTPException , UploadFile
9+ from fastapi import BackgroundTasks
10+ import json
411from kernelbot .env import env
512from libkernelbot .backend import KernelBackend
613from libkernelbot .consts import SubmissionMode
1219 Text ,
1320)
1421from libkernelbot .submission import SubmissionRequest , prepare_submission
22+ from src .kernelbot .api .main import simple_rate_limit
23+ from src .libkernelbot .leaderboard_db import LeaderboardDB
1524
1625
17- async def _handle_discord_oauth (code : str , redirect_uri : str ) -> tuple [str , str ]:
26+ async def _handle_discord_oauth (
27+ code : str , redirect_uri : str
28+ ) -> tuple [str , str ]:
1829 """Handles the Discord OAuth code exchange and user info retrieval."""
1930 client_id = env .CLI_DISCORD_CLIENT_ID
2031 client_secret = env .CLI_DISCORD_CLIENT_SECRET
2132 token_url = env .CLI_TOKEN_URL
2233 user_api_url = "https://discord.com/api/users/@me"
2334
2435 if not client_id :
25- raise HTTPException (status_code = 500 , detail = "Discord client ID not configured." )
36+ raise HTTPException (
37+ status_code = 500 , detail = "Discord client ID not configured."
38+ )
2639 if not client_secret :
27- raise HTTPException (status_code = 500 , detail = "Discord client secret not configured." )
40+ raise HTTPException (
41+ status_code = 500 , detail = "Discord client secret not configured."
42+ )
2843 if not token_url :
29- raise HTTPException (status_code = 500 , detail = "Discord token URL not configured." )
44+ raise HTTPException (
45+ status_code = 500 , detail = "Discord token URL not configured."
46+ )
3047
3148 token_data = {
3249 "client_id" : client_id ,
@@ -69,13 +86,16 @@ async def _handle_discord_oauth(code: str, redirect_uri: str) -> tuple[str, str]
6986
7087 if not user_id or not user_name :
7188 raise HTTPException (
72- status_code = 500 , detail = "Failed to retrieve user ID or username from Discord."
89+ status_code = 500 ,
90+ detail = "Failed to retrieve user ID or username from Discord." ,
7391 )
7492
7593 return user_id , user_name
7694
7795
78- async def _handle_github_oauth (code : str , redirect_uri : str ) -> tuple [str , str ]:
96+ async def _handle_github_oauth (
97+ code : str , redirect_uri : str
98+ ) -> tuple [str , str ]:
7999 """Handles the GitHub OAuth code exchange and user info retrieval."""
80100 client_id = env .CLI_GITHUB_CLIENT_ID
81101 client_secret = env .CLI_GITHUB_CLIENT_SECRET
@@ -84,9 +104,13 @@ async def _handle_github_oauth(code: str, redirect_uri: str) -> tuple[str, str]:
84104 user_api_url = "https://api.github.com/user"
85105
86106 if not client_id :
87- raise HTTPException (status_code = 500 , detail = "GitHub client ID not configured." )
107+ raise HTTPException (
108+ status_code = 500 , detail = "GitHub client ID not configured."
109+ )
88110 if not client_secret :
89- raise HTTPException (status_code = 500 , detail = "GitHub client secret not configured." )
111+ raise HTTPException (
112+ status_code = 500 , detail = "GitHub client secret not configured."
113+ )
90114
91115 token_data = {
92116 "client_id" : client_id ,
@@ -98,7 +122,9 @@ async def _handle_github_oauth(code: str, redirect_uri: str) -> tuple[str, str]:
98122 headers = {"Accept" : "application/json" } # Request JSON response for token
99123
100124 try :
101- token_response = requests .post (token_url , data = token_data , headers = headers )
125+ token_response = requests .post (
126+ token_url , data = token_data , headers = headers
127+ )
102128 token_response .raise_for_status ()
103129 except requests .exceptions .RequestException as e :
104130 raise HTTPException (
@@ -125,33 +151,20 @@ async def _handle_github_oauth(code: str, redirect_uri: str) -> tuple[str, str]:
125151 ) from e
126152
127153 user_json = user_response .json ()
128- user_id = str (user_json .get ("id" )) # GitHub ID is integer, convert to string for consistency
154+ user_id = str (
155+ user_json .get ("id" )
156+ ) # GitHub ID is integer, convert to string for consistency
129157 user_name = user_json .get ("login" ) # GitHub uses 'login' for username
130158
131159 if not user_id or not user_name :
132160 raise HTTPException (
133- status_code = 500 , detail = "Failed to retrieve user ID or username from GitHub."
161+ status_code = 500 ,
162+ detail = "Failed to retrieve user ID or username from GitHub." ,
134163 )
135164
136165 return user_id , user_name
137166
138167
139- async def _run_submission (
140- submission : SubmissionRequest , mode : SubmissionMode , backend : KernelBackend
141- ):
142- try :
143- req = prepare_submission (submission , backend )
144- except Exception as e :
145- raise HTTPException (status_code = 400 , detail = str (e )) from e
146-
147- if len (req .gpus ) != 1 :
148- raise HTTPException (status_code = 400 , detail = "Invalid GPU type" )
149-
150- reporter = MultiProgressReporterAPI ()
151- sub_id , results = await backend .submit_full (req , mode , reporter )
152- return results , [rep .get_message () + "\n " + rep .long_report for rep in reporter .runs ]
153-
154-
155168class MultiProgressReporterAPI (MultiProgressReporter ):
156169 def __init__ (self ):
157170 self .runs = []
@@ -183,3 +196,230 @@ async def display_report(self, title: str, report: RunResultReport):
183196 elif isinstance (part , Log ):
184197 self .long_report += f"\n \n ## { part .header } :\n "
185198 self .long_report += f"```\n { part .content } ```"
199+
200+
201+ async def to_submission_info (
202+ user_info : Any ,
203+ submission_mode : str ,
204+ file : UploadFile ,
205+ leaderboard_name : str ,
206+ gpu_type : str ,
207+ db_context : LeaderboardDB ,
208+ ) -> tuple [SubmissionRequest , SubmissionMode ]:
209+ user_name = user_info ["user_name" ]
210+ user_id = user_info ["user_id" ]
211+
212+ try :
213+ submission_mode_enum : SubmissionMode = SubmissionMode (
214+ submission_mode .lower ()
215+ )
216+ except ValueError :
217+ raise HTTPException (
218+ status_code = 400 ,
219+ detail = f"Invalid submission mode value: '{ submission_mode } '" ,
220+ ) from None
221+
222+ if submission_mode_enum in [SubmissionMode .PROFILE ]:
223+ raise HTTPException (
224+ status_code = 400 ,
225+ detail = "Profile submissions are not currently supported via API" ,
226+ )
227+
228+ allowed_modes = [
229+ SubmissionMode .TEST ,
230+ SubmissionMode .BENCHMARK ,
231+ SubmissionMode .LEADERBOARD ,
232+ ]
233+ if submission_mode_enum not in allowed_modes :
234+ raise HTTPException (
235+ status_code = 400 ,
236+ detail = f"Submission mode '{ submission_mode } ' is not supported for this endpoint" ,
237+ )
238+
239+ try :
240+ with db_context as db :
241+ leaderboard_item = db .get_leaderboard (leaderboard_name )
242+ gpus = leaderboard_item .get ("gpu_types" , [])
243+ if gpu_type not in gpus :
244+ supported_gpus = ", " .join (gpus ) if gpus else "None"
245+ raise HTTPException (
246+ status_code = 400 ,
247+ detail = f"GPU type '{ gpu_type } ' is not supported for "
248+ f"leaderboard '{ leaderboard_name } '. Supported GPUs: { supported_gpus } " ,
249+ )
250+ except HTTPException :
251+ raise
252+ except Exception as e :
253+ raise HTTPException (
254+ status_code = 500 ,
255+ detail = f"Internal server error while validating leaderboard/GPU: { e } " ,
256+ ) from e
257+
258+ try :
259+ submission_content = await file .read ()
260+ if not submission_content :
261+ raise HTTPException (
262+ status_code = 400 ,
263+ detail = "Empty file submitted. Please provide a file with code." ,
264+ )
265+ if len (submission_content ) > 1_000_000 :
266+ raise HTTPException (
267+ status_code = 413 ,
268+ detail = "Submission file is too large (limit: 1MB)." ,
269+ )
270+
271+ except HTTPException :
272+ raise
273+ except Exception as e :
274+ raise HTTPException (
275+ status_code = 400 , detail = f"Error reading submission file: { e } "
276+ ) from e
277+
278+ try :
279+ submission_code = submission_content .decode ("utf-8" )
280+ submission_request = SubmissionRequest (
281+ code = submission_code ,
282+ file_name = file .filename or "submission.py" ,
283+ user_id = user_id ,
284+ user_name = user_name ,
285+ gpus = [gpu_type ],
286+ leaderboard = leaderboard_name ,
287+ )
288+ except UnicodeDecodeError :
289+ raise HTTPException (
290+ status_code = 400 ,
291+ detail = "Failed to decode submission file content as UTF-8." ,
292+ ) from None
293+ except Exception as e :
294+ raise HTTPException (
295+ status_code = 500 ,
296+ detail = f"Internal server error creating submission request: { e } " ,
297+ ) from e
298+
299+ return submission_request , submission_mode_enum
300+
301+
302+ def json_serializer (obj ):
303+ """JSON serializer for objects not serializable by default json code"""
304+ if isinstance (obj , (datetime .datetime , datetime .date , datetime .time )):
305+ return obj .isoformat ()
306+ raise TypeError (f"Type { type (obj )} not serializable" )
307+
308+
309+ async def _run_submission (
310+ submission : SubmissionRequest ,
311+ mode : SubmissionMode ,
312+ backend : KernelBackend ,
313+ submission_id : Optional [int ] = None ,
314+ ):
315+ try :
316+ req = prepare_submission (submission , backend )
317+ except Exception as e :
318+ raise HTTPException (status_code = 400 , detail = str (e )) from e
319+
320+ if len (req .gpus ) != 1 :
321+ raise HTTPException (status_code = 400 , detail = "Invalid GPU type" )
322+
323+ reporter = MultiProgressReporterAPI ()
324+ sub_id , results = await backend .submit_full (
325+ req , mode , reporter , submission_id
326+ )
327+ return (
328+ results ,
329+ [rep .get_message () + "\n " + rep .long_report for rep in reporter .runs ],
330+ sub_id ,
331+ )
332+
333+
334+ def start_detached_run (
335+ submission_request : SubmissionRequest ,
336+ submission_mode_enum : SubmissionMode ,
337+ backend : KernelBackend ,
338+ db : "LeaderboardDB" ,
339+ background_tasks : BackgroundTasks ,
340+ ) -> int :
341+ """Starts a submission in the background and returns the submission id immediately."""
342+
343+ # create submission id, so that it can be return to client before task is started
344+ with db :
345+ req = submission_request
346+ sub_id = db .create_submission (
347+ leaderboard = req .leaderboard ,
348+ file_name = req .file_name ,
349+ code = req .code ,
350+ user_id = req .user_id ,
351+ time = datetime .now (),
352+ user_name = req .user_name ,
353+ )
354+
355+ # makes the task run in the background
356+ background_tasks .add_task (
357+ _run_submission ,
358+ submission_request ,
359+ submission_mode_enum ,
360+ backend ,
361+ db ,
362+ sub_id ,
363+ )
364+ return sub_id
365+
366+
367+ async def sse_stream_submission (
368+ submission_request : SubmissionRequest ,
369+ submission_mode_enum : SubmissionMode ,
370+ backend : KernelBackend ,
371+ ):
372+ start_time = time .time ()
373+ task : asyncio .Task | None = None
374+ try :
375+ task = asyncio .create_task (
376+ _run_submission (submission_request , submission_mode_enum , backend )
377+ )
378+
379+ while not task .done ():
380+ elapsed_time = time .time () - start_time
381+ yield (
382+ "event: status\n "
383+ f"data: { json .dumps ({'status' : 'processing' ,'elapsed_time' : round (elapsed_time , 2 )}, default = json_serializer )} \n \n "
384+ )
385+ try :
386+ await asyncio .wait_for (asyncio .shield (task ), timeout = 15.0 )
387+ except asyncio .TimeoutError :
388+ continue
389+ except asyncio .CancelledError :
390+ yield (
391+ "event: error\n "
392+ f"data: { json .dumps ({'status' : 'error' ,'detail' : 'Submission cancelled' }, default = json_serializer )} \n \n "
393+ )
394+ return
395+
396+ result , reports = await task
397+ result_data = {
398+ "status" : "success" ,
399+ "results" : [asdict (r ) for r in result ],
400+ "reports" : reports ,
401+ }
402+ yield "event: result\n " f"data: { json .dumps (result_data , default = json_serializer )} \n \n "
403+
404+ except HTTPException as http_exc :
405+ error_data = {
406+ "status" : "error" ,
407+ "detail" : http_exc .detail ,
408+ "status_code" : http_exc .status_code ,
409+ }
410+ yield "event: error\n " f"data: { json .dumps (error_data , default = json_serializer )} \n \n "
411+ except Exception as e :
412+ error_type = type (e ).__name__
413+ error_data = {
414+ "status" : "error" ,
415+ "detail" : f"An unexpected error occurred: { error_type } " ,
416+ "raw_error" : str (e ),
417+ }
418+ yield "event: error\n " f"data: { json .dumps (error_data , default = json_serializer )} \n \n "
419+ finally :
420+ if task and not task .done ():
421+ task .cancel ()
422+ try :
423+ await task
424+ except asyncio .CancelledError :
425+ pass
0 commit comments