kattis-cli 1.0.7__py3-none-any.whl → 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kattis_cli/client.py ADDED
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Object-oriented Kattis API client.
4
+
5
+ This class wraps login, submit and status-checking functionality previously
6
+ implemented as module-level functions in `kattis.py`.
7
+ """
8
+
9
+ from typing import List, Any
10
+ import sys
11
+ import os
12
+ import re
13
+ import time
14
+ import configparser
15
+ from bs4 import BeautifulSoup
16
+ import requests
17
+ import requests.exceptions
18
+ import requests.cookies
19
+ from rich.console import Console
20
+ from rich.align import Align
21
+ from rich.live import Live
22
+ from rich.prompt import Confirm
23
+
24
+ from kattis_cli.utils import languages
25
+ from kattis_cli.utils import config
26
+ from kattis_cli import ui
27
+ from kattis_cli.fireworks import run_fireworks
28
+
29
+
30
+ class KattisClient:
31
+ """A simple client for interacting with Kattis (login, submit, poll)."""
32
+
33
+ _HEADERS = {'User-Agent': 'kattis-cli-submit'}
34
+
35
+ _RUNNING_STATUS = 5
36
+ _COMPILE_ERROR_STATUS = 8
37
+ _ACCEPTED_STATUS = 16
38
+
39
+ _STATUS_MAP = {
40
+ 0: 'New',
41
+ 1: 'New',
42
+ 2: 'Waiting for compile',
43
+ 3: 'Compiling',
44
+ 4: 'Waiting for run',
45
+ _RUNNING_STATUS: 'Running',
46
+ 6: 'Judge Error',
47
+ 7: 'Submission Error',
48
+ _COMPILE_ERROR_STATUS: 'Compile Error',
49
+ 9: 'Run Time Error',
50
+ 10: 'Memory Limit Exceeded',
51
+ 11: 'Output Limit Exceeded',
52
+ 12: 'Time Limit Exceeded',
53
+ 13: 'Illegal Function',
54
+ 14: 'Wrong Answer',
55
+ _ACCEPTED_STATUS: 'Accepted',
56
+ }
57
+
58
+ def __init__(self) -> None:
59
+ self.console = Console()
60
+
61
+ # --- Authentication ---
62
+ def login(self, login_url: str, username: str,
63
+ password: str = '', token: str = '') -> requests.Response:
64
+ """Log in to Kattis using username and either a password or token.
65
+
66
+ Args:
67
+ login_url: Full URL to the Kattis login endpoint.
68
+ username: Kattis username or email.
69
+ password: Optional password for login.
70
+ token: Optional API token for script-based login.
71
+
72
+ Returns:
73
+ The requests.Response object from the POST request.
74
+ """
75
+ login_args = {'user': username, 'script': 'true'}
76
+ if password:
77
+ login_args['password'] = password
78
+ if token:
79
+ login_args['token'] = token
80
+
81
+ return requests.post(
82
+ login_url,
83
+ data=login_args,
84
+ headers=self._HEADERS,
85
+ timeout=10)
86
+
87
+ def login_from_config(
88
+ self,
89
+ cfg: configparser.ConfigParser) -> requests.Response:
90
+ """Log in using credentials found in a .kattisrc config.
91
+
92
+ Reads username and either password or token from the provided
93
+ ConfigParser and performs a login. Raises ConfigError when the
94
+ configuration is invalid.
95
+ """
96
+
97
+ username = cfg.get('user', 'username')
98
+ password = token = ''
99
+ try:
100
+ password = cfg.get('user', 'password')
101
+ except configparser.NoOptionError:
102
+ pass
103
+ try:
104
+ token = cfg.get('user', 'token')
105
+ except configparser.NoOptionError:
106
+ pass
107
+ if not password and not token:
108
+ raise config.ConfigError('''\
109
+ Your .kattisrc file appears corrupted. It must provide a token (or a
110
+ KATTIS password).
111
+
112
+ Please download a new .kattisrc file''')
113
+
114
+ loginurl = self.get_url(cfg, 'loginurl', 'login')
115
+ return self.login(loginurl, username, password, token)
116
+
117
+ def get_url(
118
+ self,
119
+ cfg: configparser.ConfigParser,
120
+ option: str,
121
+ default: str) -> str:
122
+ """Return a URL taken from config or constructed from hostname.
123
+
124
+ Args:
125
+ cfg: ConfigParser loaded from .kattisrc.
126
+ option: Config option name that might hold the URL.
127
+ default: Default path to append to the hostname if option
128
+ is not present.
129
+ """
130
+
131
+ if cfg.has_option('kattis', option):
132
+ return cfg.get('kattis', option)
133
+ else:
134
+ return f"https://{cfg.get('kattis', 'hostname')}/{default}"
135
+
136
+ # --- Submission ---
137
+ def submit(self,
138
+ submit_url: str,
139
+ cookies: requests.cookies.RequestsCookieJar,
140
+ problem: str,
141
+ language: str,
142
+ files: List[str],
143
+ mainclass: str,
144
+ tag: str) -> requests.Response:
145
+ """Submit a solution to the Kattis submit endpoint.
146
+
147
+ Args:
148
+ submit_url: Full URL to POST the submission to.
149
+ cookies: Requests cookie jar from an authenticated session.
150
+ problem: Problem id to submit to.
151
+ language: Language identifier to use for the submission.
152
+ files: List of file paths to include in the submission.
153
+ mainclass: Main class/file name (if applicable).
154
+ tag: Optional tag to attach to the submission.
155
+
156
+ Returns:
157
+ The requests.Response returned by the POST.
158
+ """
159
+
160
+ data = {'submit': 'true',
161
+ 'submit_ctr': 2,
162
+ 'language': language,
163
+ 'mainclass': mainclass,
164
+ 'problem': problem,
165
+ 'tag': tag,
166
+ 'script': 'true'}
167
+
168
+ sub_files = []
169
+ for f in files:
170
+ with open(f, 'rb') as sub_file:
171
+ sub_files.append(('sub_file[]',
172
+ (os.path.basename(f),
173
+ sub_file.read(),
174
+ 'application/octet-stream')))
175
+
176
+ return requests.post(
177
+ submit_url,
178
+ data=data,
179
+ files=sub_files,
180
+ cookies=cookies,
181
+ headers=self._HEADERS, timeout=10)
182
+
183
+ def get_submission_url(self, submit_response: str,
184
+ cfg: configparser.ConfigParser) -> str:
185
+ """Extract the submission URL from the server submission reply.
186
+
187
+ The Kattis submit reply typically contains a line indicating the
188
+ Submission ID. This builds the full submissions URL from config
189
+ and returns the specific submission URL.
190
+ """
191
+
192
+ m = re.search(r'Submission ID: (\d+)', submit_response)
193
+ if m:
194
+ submissions_url = self.get_url(
195
+ cfg, 'submissionsurl', 'submissions')
196
+ submission_id = m.group(1)
197
+ return f'{submissions_url}/{submission_id}'
198
+ else:
199
+ raise config.ConfigError(
200
+ 'Could not find submission ID in response')
201
+
202
+ def get_submission_status(
203
+ self,
204
+ submission_url: str,
205
+ cookies: requests.cookies.RequestsCookieJar) -> Any:
206
+ """Poll the submission status JSON endpoint and return parsed JSON.
207
+
208
+ Args:
209
+ submission_url: Base URL for the submission (without ?json).
210
+ cookies: Authenticated session cookies.
211
+
212
+ Returns:
213
+ Parsed JSON as returned by the status endpoint.
214
+ """
215
+
216
+ reply = requests.get(
217
+ submission_url + '?json',
218
+ cookies=cookies,
219
+ headers=self._HEADERS, timeout=10)
220
+ return reply.json()
221
+
222
+ # --- HTML parsing ---
223
+ def parse_row_html(self, html: str) -> Any:
224
+ """Parse the HTML snippet for a single submission row.
225
+
226
+ The method extracts runtime, status, language and per-test results
227
+ from the HTML snippet returned by the Kattis submission API.
228
+ """
229
+
230
+ runtime = '❓ s'
231
+ status = '❓'
232
+ language = '❓'
233
+ test_status = '❓/❓'
234
+ soup = BeautifulSoup(html, 'html.parser')
235
+ tr_submission: Any = soup.find("tr", {"data-submission-id": True})
236
+ if tr_submission:
237
+ td_cputime = tr_submission.find("td", {"data-type": "cpu"})
238
+ if td_cputime:
239
+ runtime = td_cputime.text.strip().replace(' ', ' ')
240
+ if not runtime:
241
+ runtime = '❓ s'
242
+ div_status = tr_submission.find(
243
+ "div", {"class": "status"}, recursive=True)
244
+ if div_status:
245
+ status = div_status.text.strip()
246
+ else:
247
+ status = '❓'
248
+ td_lang = tr_submission.find(
249
+ "td", {"data-type": "lang"}, recursive=False)
250
+ if td_lang:
251
+ language = td_lang.text.strip()
252
+ else:
253
+ language = '❓'
254
+ td_test_cases = tr_submission.find(
255
+ "td", {"data-type": "testcases"})
256
+ if td_test_cases:
257
+ test_status = td_test_cases.text.strip()
258
+
259
+ i_tag = soup.find_all("i", {"class": True})
260
+ test_result = []
261
+ for num, i in enumerate(i_tag):
262
+ if 'title' not in i.attrs:
263
+ continue
264
+ title = i['title'].split(':')
265
+ if 'Accepted' in title[-1]:
266
+ test_result.append(f'{num}✅')
267
+ elif 'not checked' in title[-1]:
268
+ test_result.append(f'{num}❓')
269
+ else:
270
+ test_result.append(f'{num}❌')
271
+ return runtime, status, language, test_status, test_result
272
+
273
+ # --- High level flows ---
274
+ def show_kattis_judgement(self, problemid: str, submission_url: str,
275
+ cfg: configparser.ConfigParser) -> None:
276
+ """Display a live view of the Kattis judgement for a submission.
277
+
278
+ This will poll the Kattis status endpoint and render a live
279
+ textual UI until the submission reaches a final state.
280
+ """
281
+
282
+ config_data = ui.show_problem_metadata(problemid)
283
+ config_data['submissions'] += 1
284
+ console = Console()
285
+ login_reply = self.login_from_config(cfg)
286
+ title = '\n[bold blue] '
287
+ title += ':cat: Kattis Judgement Results :cat:[/]\n'
288
+ status_id = 0
289
+ with Live(console=console, screen=False,
290
+ refresh_per_second=10) as kattis_live:
291
+
292
+ while True:
293
+ time.sleep(0.1)
294
+ result = self.get_submission_status(submission_url,
295
+ login_reply.cookies)
296
+ rt, status, lang, t_status, t_results = self.parse_row_html(
297
+ result['row_html'])
298
+ status_id = int(result['status_id'])
299
+ if status_id > 4 and status_id < 16:
300
+ judgement = f'[bold red] {status}[/]'
301
+ else:
302
+ judgement = f'[bold yellow] {status}[/]'
303
+ if status_id == 12:
304
+ runtime = f'[bold red] {rt}[/]'
305
+ else:
306
+ runtime = f'[bold yellow] {rt}[/]'
307
+
308
+ text = title + f'\n[bold blue]JUDGEMENT:[/] {judgement}'
309
+ text += f'\t[bold blue]LANGUAGE:[/] [bold yellow]{lang}[/]'
310
+ text += f'\t[bold blue]RUNTIME:[/] {runtime}'
311
+ text += f'\t[bold deep_pink3][link={submission_url}]'
312
+ text += 'VIEW DETAILS ON KATTIS[/link][/]\n\n'
313
+ text += (f'[bold blue]TESTCASES:[/] '
314
+ f'[bold green]{t_status}[/]\n\n')
315
+ if status_id < 5:
316
+ test_cases = (
317
+ '🤞🏻🤞🏻🤞🏻🤞🏻🤞🏻 '
318
+ '[bold yellow]WAITING...[/] '
319
+ '🤞🏻🤞🏻🤞🏻🤞🏻🤞🏻\u200d'
320
+ )
321
+ else:
322
+ test_cases = ' '.join(t_results) + '\n'
323
+ text += test_cases
324
+ kattis_live.update(Align.center(text))
325
+ if status_id > 5:
326
+ kattis_live.stop()
327
+ break
328
+ if status_id == self._ACCEPTED_STATUS:
329
+ verdict = '👍🎆🔥🎈🎈 [bold yellow]YAY!! KEEP GOING...[/] 🎈🎈👍🎆🔥'
330
+ console.print()
331
+ config_data['accepted'] += 1
332
+ # Try to launch an external fireworks script in a separate
333
+ # process. This keeps GUI code out of the main client process
334
+ # and avoids import-time side effects.
335
+ try:
336
+ run_fireworks()
337
+ except Exception:
338
+ # Best-effort: failures to launch fireworks should not
339
+ # affect normal client operation.
340
+ pass
341
+ else:
342
+ verdict = '💪🧐💪 [bold green]SORRY![/] 🧐💪🧐'
343
+ console.print(Align.center(verdict))
344
+ config.update_problem_metadata(problemid, config_data)
345
+ ui.show_problem_metadata(problemid)
346
+
347
+ def get_login_reply(
348
+ self,
349
+ cfg: configparser.ConfigParser) -> requests.Response:
350
+ """Attempt login using config and handle common error modes.
351
+
352
+ Returns the successful login Response or exits the process when
353
+ login cannot be completed.
354
+ """
355
+
356
+ try:
357
+ login_reply = self.login_from_config(cfg)
358
+ except config.ConfigError as exc:
359
+ self.console.print(exc)
360
+ sys.exit(1)
361
+ except requests.exceptions.RequestException as err:
362
+ self.console.print('Login connection failed:', err)
363
+ sys.exit(1)
364
+
365
+ if not login_reply.status_code == 200:
366
+ self.console.print('Login failed.')
367
+ if login_reply.status_code == 403:
368
+ self.console.print(
369
+ 'Incorrect username or password/token (403)')
370
+ elif login_reply.status_code == 404:
371
+ self.console.print('Incorrect login URL (404)')
372
+ else:
373
+ self.console.print('Status code:', login_reply.status_code)
374
+ sys.exit(1)
375
+ return login_reply
376
+
377
+ def submit_solution(self, files: List[str], problemid: str,
378
+ language: str, mainclass: str,
379
+ tag: str, force: bool) -> None:
380
+ """High-level helper to submit a solution from file paths.
381
+
382
+ This wraps login-from-config, optional confirmation UI and the
383
+ lower-level submit call. On success it may follow-up by showing
384
+ the live judgement UI.
385
+ """
386
+
387
+ try:
388
+ cfg = config.get_kattisrc()
389
+ except config.ConfigError as exc:
390
+ self.console.print(exc)
391
+ sys.exit(1)
392
+ login_reply = self.get_login_reply(cfg)
393
+ if not files:
394
+ self.console.print('No files specified')
395
+ sys.exit(1)
396
+
397
+ files = sorted(list(set(files)))
398
+
399
+ submit_url = self.get_url(cfg, 'submissionurl', 'submit')
400
+
401
+ if not force:
402
+ # reuse module-level confirm UI
403
+ self._confirm_or_die(problemid, language, files, mainclass, tag)
404
+
405
+ try:
406
+ result = self.submit(submit_url,
407
+ login_reply.cookies,
408
+ problemid,
409
+ language,
410
+ files,
411
+ mainclass,
412
+ tag)
413
+ except requests.exceptions.RequestException as err:
414
+ self.console.print('Submit connection failed:',
415
+ err, style='bold red')
416
+ sys.exit(1)
417
+
418
+ if result.status_code != 200:
419
+ self.console.print('Submission failed.', style='bold red')
420
+ if result.status_code == 403:
421
+ self.console.print('Access denied (403)', style='bold red')
422
+ elif result.status_code == 404:
423
+ self.console.print('Incorrect submit URL (404)')
424
+ else:
425
+ self.console.print(
426
+ 'Status code:', result.status_code, style='bold red')
427
+ sys.exit(1)
428
+
429
+ plain_result = result.content.decode('utf-8').replace('<br />', '\n')
430
+ self.console.print(f'[bold blue]{plain_result}[/]')
431
+
432
+ submission_url = None
433
+ try:
434
+ submission_url = self.get_submission_url(plain_result, cfg)
435
+ except configparser.NoOptionError:
436
+ pass
437
+
438
+ if submission_url:
439
+ self.show_kattis_judgement(problemid, submission_url, cfg)
440
+
441
+ def _confirm_or_die(self, problem: str, language: str,
442
+ files: List[str], mainclass: str,
443
+ tag: str) -> None:
444
+ """Ask the user to confirm the submission or exit the program.
445
+
446
+ This uses the rich Confirm prompt to request user confirmation
447
+ before submitting to Kattis.
448
+ """
449
+
450
+ console = Console()
451
+ console.clear()
452
+ console.print('Problem:', problem)
453
+ console.print('Language:', language)
454
+ console.print('Files:', ', '.join(files))
455
+ if mainclass:
456
+ if language in languages.GUESS_MAINFILE:
457
+ console.print('Main file:', mainclass)
458
+ else:
459
+ console.print('Mainclass:', mainclass)
460
+ if tag:
461
+ console.print('Tag:', tag)
462
+ if not Confirm.ask('Submit to Kattis', default=True):
463
+ console.print('Cancelling...')
464
+ sys.exit(1)